@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
@@ -0,0 +1,69 @@
1
+ import type { CoreServices, PikkuRawWire } from '../../types/core.types.js'
2
+ import { PikkuError, addError } from '../../errors/error-handler.js'
3
+
4
+ /**
5
+ * How a `wireRemoteAddon` consumer supplies the token the hosted addon expects.
6
+ * This is a CLIENT authenticating to a hosted library — not the trusted mesh.
7
+ */
8
+ export type RemoteAddonAuthBinding =
9
+ | { credentialId: string }
10
+ | { secretId: string }
11
+ | {
12
+ resolve: (
13
+ services: CoreServices,
14
+ wire: PikkuRawWire
15
+ ) => string | Promise<string>
16
+ }
17
+
18
+ /** The addon's auth was bound but the source produced no token — fail closed. */
19
+ export class RemoteAddonAuthError extends PikkuError {
20
+ public readonly namespace: string
21
+ constructor(namespace: string, detail: string) {
22
+ super(`Remote addon '${namespace}' auth could not be resolved: ${detail}`)
23
+ this.namespace = namespace
24
+ }
25
+ }
26
+ addError(RemoteAddonAuthError, {
27
+ status: 401,
28
+ message: 'Remote addon authentication could not be resolved.',
29
+ })
30
+
31
+ /**
32
+ * Resolve the bearer token for a remote addon call from the consumer's bound
33
+ * source. Returns `null` only when no auth is bound (a public surface).
34
+ *
35
+ * Security: the resolved token is never logged or traced; per-user credentials
36
+ * are read via the wire (scoped to `pikkuUserId`); an empty result fails closed.
37
+ */
38
+ export async function resolveRemoteAddonToken(
39
+ auth: RemoteAddonAuthBinding | undefined,
40
+ services: CoreServices,
41
+ wire: PikkuRawWire,
42
+ namespace: string
43
+ ): Promise<string | null> {
44
+ if (!auth) {
45
+ // No auth bound → the addon declares its remote surface public.
46
+ return null
47
+ }
48
+
49
+ let token: unknown
50
+ if ('credentialId' in auth) {
51
+ if (typeof wire.getCredential !== 'function') {
52
+ throw new RemoteAddonAuthError(
53
+ namespace,
54
+ `credentialId '${auth.credentialId}' requires a wire with credential access`
55
+ )
56
+ }
57
+ token = await wire.getCredential(auth.credentialId)
58
+ } else if ('secretId' in auth) {
59
+ token = await services.secrets.getSecret(auth.secretId)
60
+ } else if (typeof auth.resolve === 'function') {
61
+ token = await auth.resolve(services, wire)
62
+ }
63
+
64
+ if (token === null || token === undefined || token === '') {
65
+ throw new RemoteAddonAuthError(namespace, 'resolved token was empty')
66
+ }
67
+
68
+ return String(token)
69
+ }
@@ -6,9 +6,13 @@ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
6
6
  import {
7
7
  ContextAwareRPCService,
8
8
  RPCNotFoundError,
9
+ RemoteAddonRequestError,
9
10
  resolveNamespace,
10
11
  rpcService,
11
12
  } from './rpc-runner.js'
13
+ import { RemoteAddonAuthError } from './remote-addon-auth.js'
14
+ import { wireAddon } from './wire-addon.js'
15
+ import { wireRemoteAddon } from './wire-remote-addon.js'
12
16
 
13
17
  const createLogger = () => ({
14
18
  debug: () => {},
@@ -207,28 +211,12 @@ describe('ContextAwareRPCService.rpc', () => {
207
211
  { requiresAuth: true }
208
212
  )
209
213
 
210
- const seenTags: string[][] = []
211
- const addonTagGroup = pikkuState('@addon/stripe', 'permissions', 'tagGroup')
212
- addonTagGroup['addon-tag'] = [
213
- async (_services: any, _data: any, wire: any) => {
214
- seenTags.push(['addon-tag', wire.wireType])
215
- return true
216
- },
217
- ] as never
218
- addonTagGroup['function-tag'] = [
219
- async (_services: any, _data: any, wire: any) => {
220
- seenTags.push(['function-tag', wire.wireType])
221
- return true
222
- },
223
- ] as never
224
-
225
214
  const result = await service.rpc('stripe:createCharge', { amount: 10 })
226
215
 
227
216
  assert.deepEqual(result, {
228
217
  data: { amount: 10 },
229
218
  auth: 'http',
230
219
  })
231
- assert.deepEqual(seenTags, [['addon-tag', 'http']])
232
220
  })
233
221
 
234
222
  test('falls back to deploymentService when rpc meta is missing', async () => {
@@ -492,6 +480,321 @@ describe('ContextAwareRPCService.remote', () => {
492
480
  })
493
481
  })
494
482
 
483
+ describe('multi-instance addons', () => {
484
+ const createSecretService = () => ({
485
+ getSecret: async (key: string) => `secret:${key}`,
486
+ hasSecret: async () => true,
487
+ setSecret: async () => {},
488
+ deleteSecret: async () => {},
489
+ getSecrets: async () => ({}),
490
+ })
491
+
492
+ test('resolves per-instance secretOverrides and caches singletons per namespace', async () => {
493
+ const createdServices: any[] = []
494
+ pikkuState('@addon/slack', 'package', 'factories', {
495
+ createSingletonServices: async (_config: any, parent: any) => {
496
+ const token = await parent.secrets.getSecret('slackToken')
497
+ const svc = { logger: createLogger(), token }
498
+ createdServices.push(svc)
499
+ return svc
500
+ },
501
+ } as never)
502
+ registerFunction(
503
+ 'send',
504
+ async (services: any) => ({ token: services.token }),
505
+ {
506
+ packageName: '@addon/slack',
507
+ }
508
+ )
509
+
510
+ wireAddon({
511
+ name: 'slack-marketing',
512
+ package: '@addon/slack',
513
+ secretOverrides: { slackToken: 'marketing_secret' },
514
+ })
515
+ wireAddon({
516
+ name: 'slack-support',
517
+ package: '@addon/slack',
518
+ secretOverrides: { slackToken: 'support_secret' },
519
+ })
520
+
521
+ const service = new ContextAwareRPCService(
522
+ createServices({ secrets: createSecretService() }),
523
+ {} as never,
524
+ { requiresAuth: false }
525
+ )
526
+
527
+ const marketing = await service.rpc('slack-marketing:send', {})
528
+ const support = await service.rpc('slack-support:send', {})
529
+
530
+ assert.deepEqual(marketing, { token: 'secret:marketing_secret' })
531
+ assert.deepEqual(support, { token: 'secret:support_secret' })
532
+ assert.equal(createdServices.length, 2)
533
+ assert.notEqual(createdServices[0], createdServices[1])
534
+ })
535
+
536
+ test('reuses the same per-namespace singleton across intra-addon sibling calls', async () => {
537
+ let factoryCalls = 0
538
+ pikkuState('@addon/slack', 'package', 'factories', {
539
+ createSingletonServices: async (_config: any, parent: any) => {
540
+ factoryCalls++
541
+ return {
542
+ logger: createLogger(),
543
+ token: await parent.secrets.getSecret('slackToken'),
544
+ }
545
+ },
546
+ } as never)
547
+
548
+ registerFunction(
549
+ 'leaf',
550
+ async (services: any) => ({ token: services.token }),
551
+ {
552
+ packageName: '@addon/slack',
553
+ }
554
+ )
555
+ registerFunction(
556
+ 'root',
557
+ async (_services: any, _data: any, wire: any) =>
558
+ wire.rpc.invoke('leaf', {}),
559
+ { packageName: '@addon/slack' }
560
+ )
561
+
562
+ wireAddon({
563
+ name: 'slack-marketing',
564
+ package: '@addon/slack',
565
+ secretOverrides: { slackToken: 'marketing_secret' },
566
+ })
567
+
568
+ const service = new ContextAwareRPCService(
569
+ createServices({ secrets: createSecretService() }),
570
+ {} as never,
571
+ { requiresAuth: false }
572
+ )
573
+
574
+ const result = await service.rpc('slack-marketing:root', {})
575
+
576
+ assert.deepEqual(result, { token: 'secret:marketing_secret' })
577
+ assert.equal(factoryCalls, 1)
578
+ })
579
+
580
+ test('resolves per-instance credentialOverrides via wire.getCredential', async () => {
581
+ pikkuState('@addon/slack', 'package', 'factories', {
582
+ createSingletonServices: async () => ({ logger: createLogger() }),
583
+ } as never)
584
+ registerFunction(
585
+ 'whoami',
586
+ async (_services: any, _data: any, wire: any) => ({
587
+ cred: await wire.getCredential('slack'),
588
+ }),
589
+ { packageName: '@addon/slack' }
590
+ )
591
+
592
+ wireAddon({
593
+ name: 'slack-marketing',
594
+ package: '@addon/slack',
595
+ credentialOverrides: { slack: 'marketing_cred' },
596
+ })
597
+ wireAddon({
598
+ name: 'slack-support',
599
+ package: '@addon/slack',
600
+ credentialOverrides: { slack: 'support_cred' },
601
+ })
602
+
603
+ const credentialService = {
604
+ getAll: async (_userId: string) => ({
605
+ marketing_cred: { token: 'm' },
606
+ support_cred: { token: 's' },
607
+ }),
608
+ }
609
+
610
+ const service = new ContextAwareRPCService(
611
+ createServices({ credentialService }),
612
+ { pikkuUserId: 'user-1' } as never,
613
+ { requiresAuth: false }
614
+ )
615
+
616
+ const marketing = await service.rpc('slack-marketing:whoami', {})
617
+ const support = await service.rpc('slack-support:whoami', {})
618
+
619
+ assert.deepEqual(marketing, { cred: { token: 'm' } })
620
+ assert.deepEqual(support, { cred: { token: 's' } })
621
+ })
622
+ })
623
+
624
+ describe('wireRemoteAddon dispatch', () => {
625
+ const realFetch = globalThis.fetch
626
+ let calls: Array<{ url: string; init: any }> = []
627
+
628
+ const stubFetch = (
629
+ response: { status?: number; body?: unknown } = {}
630
+ ): void => {
631
+ const { status = 200, body = { ok: true } } = response
632
+ calls = []
633
+ globalThis.fetch = (async (url: any, init: any) => {
634
+ calls.push({ url: String(url), init })
635
+ return {
636
+ ok: status >= 200 && status < 300,
637
+ status,
638
+ text: async () => (body === undefined ? '' : JSON.stringify(body)),
639
+ }
640
+ }) as any
641
+ }
642
+
643
+ const restoreFetch = () => {
644
+ globalThis.fetch = realFetch
645
+ }
646
+
647
+ const secretService = () =>
648
+ ({
649
+ getSecret: async (key: string) => `secret-value-for-${key}`,
650
+ getSecrets: async () => ({}),
651
+ hasSecret: async () => true,
652
+ setSecret: async () => {},
653
+ deleteSecret: async () => {},
654
+ }) as never
655
+
656
+ test('POSTs to /remote/rpc/<fn> with { rpcName, data } and a platform bearer token', async () => {
657
+ stubFetch({ body: { echoed: 42 } })
658
+ try {
659
+ wireRemoteAddon({
660
+ name: 'registry',
661
+ package: '@pikkufabric/addon-registry',
662
+ serverUrl: 'https://api.example.com/',
663
+ auth: { secretId: 'REGISTRY_TOKEN' },
664
+ })
665
+
666
+ const service = new ContextAwareRPCService(
667
+ createServices({ secrets: secretService() }),
668
+ { traceId: 'trace-r' } as never,
669
+ {}
670
+ )
671
+
672
+ const result = await service.rpc('registry:getOpenApi', { name: 'stripe' })
673
+
674
+ assert.deepEqual(result, { echoed: 42 })
675
+ assert.equal(calls.length, 1)
676
+ assert.equal(
677
+ calls[0]!.url,
678
+ 'https://api.example.com/remote/rpc/getOpenApi'
679
+ )
680
+ assert.equal(calls[0]!.init.method, 'POST')
681
+ assert.deepEqual(JSON.parse(calls[0]!.init.body), {
682
+ rpcName: 'getOpenApi',
683
+ data: { name: 'stripe' },
684
+ })
685
+ assert.equal(
686
+ calls[0]!.init.headers.authorization,
687
+ 'Bearer secret-value-for-REGISTRY_TOKEN'
688
+ )
689
+ assert.equal(calls[0]!.init.headers['x-trace-id'], 'trace-r')
690
+ } finally {
691
+ restoreFetch()
692
+ }
693
+ })
694
+
695
+ test('resolves a per-user credential token via wire.getCredential', async () => {
696
+ stubFetch()
697
+ try {
698
+ wireRemoteAddon({
699
+ name: 'registry',
700
+ package: '@pikkufabric/addon-registry',
701
+ serverUrl: () => 'https://api.example.com',
702
+ auth: { credentialId: 'fabricRegistryToken' },
703
+ })
704
+
705
+ const service = new ContextAwareRPCService(
706
+ createServices(),
707
+ {
708
+ pikkuUserId: 'user-9',
709
+ getCredential: async (name: string) =>
710
+ name === 'fabricRegistryToken' ? 'user-token-9' : null,
711
+ } as never,
712
+ {}
713
+ )
714
+
715
+ await service.rpc('registry:getOpenApi', {})
716
+ assert.equal(
717
+ calls[0]!.init.headers.authorization,
718
+ 'Bearer user-token-9'
719
+ )
720
+ } finally {
721
+ restoreFetch()
722
+ }
723
+ })
724
+
725
+ test('omits the Authorization header for a public (no auth) surface', async () => {
726
+ stubFetch()
727
+ try {
728
+ wireRemoteAddon({
729
+ name: 'registry',
730
+ package: '@pikkufabric/addon-registry',
731
+ serverUrl: 'https://api.example.com',
732
+ })
733
+
734
+ const service = new ContextAwareRPCService(
735
+ createServices(),
736
+ {} as never,
737
+ {}
738
+ )
739
+
740
+ await service.rpc('registry:listOpenApis', {})
741
+ assert.equal(calls[0]!.init.headers.authorization, undefined)
742
+ } finally {
743
+ restoreFetch()
744
+ }
745
+ })
746
+
747
+ test('fails closed when a bound credential resolves empty', async () => {
748
+ stubFetch()
749
+ try {
750
+ wireRemoteAddon({
751
+ name: 'registry',
752
+ package: '@pikkufabric/addon-registry',
753
+ serverUrl: 'https://api.example.com',
754
+ auth: { credentialId: 'fabricRegistryToken' },
755
+ })
756
+
757
+ const service = new ContextAwareRPCService(
758
+ createServices(),
759
+ { getCredential: async () => null } as never,
760
+ {}
761
+ )
762
+
763
+ await assert.rejects(
764
+ () => service.rpc('registry:getOpenApi', {}),
765
+ RemoteAddonAuthError
766
+ )
767
+ assert.equal(calls.length, 0)
768
+ } finally {
769
+ restoreFetch()
770
+ }
771
+ })
772
+
773
+ test('throws RemoteAddonRequestError on a non-2xx response', async () => {
774
+ stubFetch({ status: 503, body: { error: 'down' } })
775
+ try {
776
+ wireRemoteAddon({
777
+ name: 'registry',
778
+ package: '@pikkufabric/addon-registry',
779
+ serverUrl: 'https://api.example.com',
780
+ })
781
+
782
+ const service = new ContextAwareRPCService(
783
+ createServices(),
784
+ {} as never,
785
+ {}
786
+ )
787
+
788
+ await assert.rejects(
789
+ () => service.rpc('registry:getOpenApi', {}),
790
+ RemoteAddonRequestError
791
+ )
792
+ } finally {
793
+ restoreFetch()
794
+ }
795
+ })
796
+ })
797
+
495
798
  describe('rpcService.getContextRPCService', () => {
496
799
  test('builds a typed rpc facade with depth and bound methods', async () => {
497
800
  pikkuState(null, 'rpc', 'meta').echo = 'echoFunc'
@@ -6,10 +6,13 @@ import type {
6
6
  import type { SessionService } from '../../services/user-session-service.js'
7
7
  import type { CoreUserSession } from '../../types/core.types.js'
8
8
  import { runPikkuFunc } from '../../function/function-runner.js'
9
+ import type { AddonInstance } from './addon-runner.js'
10
+ import { addonInstanceForNamespace } from './addon-runner.js'
9
11
  import { pikkuState } from '../../pikku-state.js'
10
12
  import { PikkuError, addError } from '../../errors/error-handler.js'
11
13
  import type { PikkuRPC, ResolvedFunction } from './rpc-types.js'
12
14
  import { parseVersionedId } from '../../version.js'
15
+ import { resolveRemoteAddonToken } from './remote-addon-auth.js'
13
16
 
14
17
  export class RPCNotFoundError extends PikkuError {
15
18
  public readonly rpcName: string
@@ -23,11 +26,43 @@ addError(RPCNotFoundError, {
23
26
  mcpCode: -32601,
24
27
  message: 'RPC function not found.',
25
28
  })
29
+
30
+ /** A `wireRemoteAddon` namespace is missing a usable `serverUrl`. */
31
+ export class RemoteAddonConfigError extends PikkuError {
32
+ constructor(namespace: string, detail: string) {
33
+ super(`Remote addon '${namespace}' is misconfigured: ${detail}`)
34
+ }
35
+ }
36
+ addError(RemoteAddonConfigError, {
37
+ status: 500,
38
+ message: 'Remote addon is misconfigured.',
39
+ })
40
+
41
+ /** The hosted addon returned a non-2xx response for a remote RPC. */
42
+ export class RemoteAddonRequestError extends PikkuError {
43
+ public readonly httpStatus: number
44
+ constructor(
45
+ namespace: string,
46
+ fnName: string,
47
+ status: number,
48
+ detail: string
49
+ ) {
50
+ super(
51
+ `Remote addon '${namespace}:${fnName}' returned ${status}${detail ? `: ${detail}` : ''}`
52
+ )
53
+ this.httpStatus = status
54
+ }
55
+ }
56
+ addError(RemoteAddonRequestError, {
57
+ status: 502,
58
+ message: 'Remote addon request failed.',
59
+ })
26
60
  import type { AIAgentInput } from '../ai-agent/ai-agent.types.js'
27
61
  import type { AIStreamChannel } from '../ai-agent/ai-agent.types.js'
28
62
  import type { StreamAIAgentOptions } from '../ai-agent/ai-agent-prepare.js'
29
63
  import { runAIAgent, resumeAIAgentSync } from '../ai-agent/ai-agent-runner.js'
30
64
  import { streamAIAgent, resumeAIAgent } from '../ai-agent/ai-agent-stream.js'
65
+ import { wrapChannelWithAGUI } from '../ai-agent/ai-agent-agui.js'
31
66
 
32
67
  /**
33
68
  * Resolve a namespaced function reference to package and function names
@@ -164,6 +199,12 @@ export class ContextAwareRPCService {
164
199
  // 'namespace:func' boundary via invokeAddonFunction.
165
200
  try {
166
201
  const resolved = resolvePikkuFunction(funcName, this.packageName)
202
+ const addonInstance = resolved.packageName
203
+ ? addonInstanceForNamespace(
204
+ this.wire.addonNamespace,
205
+ resolved.packageName
206
+ )
207
+ : undefined
167
208
  return await runPikkuFunc<In, Out>(
168
209
  'rpc',
169
210
  funcName,
@@ -174,6 +215,7 @@ export class ContextAwareRPCService {
174
215
  data: () => data,
175
216
  wire: updatedWire,
176
217
  packageName: resolved.packageName,
218
+ addonInstance,
177
219
  }
178
220
  )
179
221
  } catch (e) {
@@ -213,6 +255,21 @@ export class ContextAwareRPCService {
213
255
  throw new RPCNotFoundError(namespacedFunction)
214
256
  }
215
257
 
258
+ const namespace = namespacedFunction.slice(
259
+ 0,
260
+ namespacedFunction.indexOf(':')
261
+ )
262
+
263
+ // wireRemoteAddon: the addon ships as a devDependency (types only) and its
264
+ // handlers run on the host — dispatch over HTTP, not through local meta.
265
+ if (resolved.addonConfig?.remote) {
266
+ return this.invokeRemoteAddonFunction<In, Out>(
267
+ namespace,
268
+ resolved.function,
269
+ data
270
+ )
271
+ }
272
+
216
273
  // Get the function meta from the addon package
217
274
  // Addon packages use function meta, not RPC meta
218
275
  const addonFunctionMeta = pikkuState(resolved.package, 'function', 'meta')
@@ -228,6 +285,15 @@ export class ContextAwareRPCService {
228
285
  ...(funcMeta.tags ?? []),
229
286
  ]
230
287
 
288
+ // The namespace is the consumer-facing wireAddon name; it selects the
289
+ // per-instance singleton services and secret/variable/credential overrides.
290
+ const addonInstance: AddonInstance = {
291
+ namespace,
292
+ secretOverrides: resolved.addonConfig?.secretOverrides,
293
+ variableOverrides: resolved.addonConfig?.variableOverrides,
294
+ credentialOverrides: resolved.addonConfig?.credentialOverrides,
295
+ }
296
+
231
297
  // Execute the function using runPikkuFunc with the addon package's state
232
298
  // We use the parent services (this.services) since addon packages share services
233
299
  // Pass the function's tags so tag-based middleware/permissions are applied
@@ -238,9 +304,78 @@ export class ContextAwareRPCService {
238
304
  wire,
239
305
  packageName: resolved.package,
240
306
  tags,
307
+ addonInstance,
241
308
  })
242
309
  }
243
310
 
311
+ /**
312
+ * Dispatch a `wireRemoteAddon` RPC over HTTP to the hosting service.
313
+ *
314
+ * The consumer sends the addon's own function name (not the namespaced form)
315
+ * to the host's `/remote/rpc/:rpcName` endpoint, authenticating as a client
316
+ * with the token bound in `wireRemoteAddon({ auth })`. The addon's handlers
317
+ * live on the host, so there is no local function meta to resolve.
318
+ */
319
+ private async invokeRemoteAddonFunction<In = any, Out = any>(
320
+ namespace: string,
321
+ fnName: string,
322
+ data: In
323
+ ): Promise<Out> {
324
+ const cfg = pikkuState(null, 'addons', 'packages').get(namespace)
325
+ if (!cfg?.remote) {
326
+ throw new RPCNotFoundError(`${namespace}:${fnName}`)
327
+ }
328
+
329
+ const serverUrl =
330
+ typeof cfg.serverUrl === 'function'
331
+ ? await cfg.serverUrl(this.services)
332
+ : cfg.serverUrl
333
+ if (!serverUrl) {
334
+ throw new RemoteAddonConfigError(namespace, 'serverUrl resolved empty')
335
+ }
336
+
337
+ const remoteFn = cfg.remoteName ? cfg.remoteName(fnName) : fnName
338
+ const token = await resolveRemoteAddonToken(
339
+ cfg.remoteAuth,
340
+ this.services,
341
+ this.wire,
342
+ namespace
343
+ )
344
+
345
+ const headers: Record<string, string> = {
346
+ 'content-type': 'application/json',
347
+ accept: 'application/json',
348
+ }
349
+ if (token) {
350
+ headers.authorization = `Bearer ${token}`
351
+ }
352
+ if (this.wire.traceId) {
353
+ headers['x-trace-id'] = this.wire.traceId
354
+ }
355
+
356
+ const base = serverUrl.replace(/\/+$/, '')
357
+ const res = await fetch(
358
+ `${base}/remote/rpc/${encodeURIComponent(remoteFn)}`,
359
+ {
360
+ method: 'POST',
361
+ headers,
362
+ body: JSON.stringify({ rpcName: remoteFn, data }),
363
+ }
364
+ )
365
+
366
+ if (!res.ok) {
367
+ // Best-effort body read to enrich the thrown error (mirrors postRpc).
368
+ const detail = (await res.text().catch(() => '')).slice(0, 300)
369
+ throw new RemoteAddonRequestError(namespace, remoteFn, res.status, detail)
370
+ }
371
+
372
+ if (res.status === 204) {
373
+ return undefined as Out
374
+ }
375
+ const text = await res.text()
376
+ return (text ? JSON.parse(text) : undefined) as Out
377
+ }
378
+
244
379
  public async rpcWithWire<In = any, Out = any>(
245
380
  rpcName: string,
246
381
  data: In,
@@ -257,12 +392,19 @@ export class ContextAwareRPCService {
257
392
 
258
393
  try {
259
394
  const resolved = resolvePikkuFunction(rpcName, this.packageName)
395
+ const addonInstance = resolved.packageName
396
+ ? addonInstanceForNamespace(
397
+ this.wire.addonNamespace,
398
+ resolved.packageName
399
+ )
400
+ : undefined
260
401
  return await runPikkuFunc<In, Out>('rpc', rpcName, resolved.pikkuFuncId, {
261
402
  auth: this.options.requiresAuth,
262
403
  singletonServices: this.services,
263
404
  data: () => data,
264
405
  wire: mergedWire,
265
406
  packageName: resolved.packageName,
407
+ addonInstance,
266
408
  })
267
409
  } catch (e) {
268
410
  if (e instanceof RPCNotFoundError && this.services.deploymentService) {
@@ -338,16 +480,26 @@ export class ContextAwareRPCService {
338
480
  ) => {
339
481
  const channel = this.wire.channel as unknown as AIStreamChannel
340
482
  if (!channel) throw new Error('No channel available for streaming')
483
+ let currentRunId: string | undefined
341
484
  await streamAIAgent(
342
485
  agentName,
343
486
  input,
344
- channel,
487
+ wrapChannelWithAGUI(channel, {
488
+ threadId: input.threadId,
489
+ getRunId: () => currentRunId,
490
+ }),
345
491
  {
346
492
  sessionService: this.options.sessionService,
347
493
  getCredential: this.wire.getCredential?.bind(this.wire),
348
494
  },
349
495
  undefined,
350
- options
496
+ {
497
+ ...options,
498
+ onRunCreated: (runId) => {
499
+ currentRunId = runId
500
+ options?.onRunCreated?.(runId)
501
+ },
502
+ }
351
503
  )
352
504
  },
353
505
  resume: async (
@@ -359,7 +511,7 @@ export class ContextAwareRPCService {
359
511
  if (!channel) throw new Error('No channel available for streaming')
360
512
  await resumeAIAgent(
361
513
  { runId, ...input },
362
- channel,
514
+ wrapChannelWithAGUI(channel, { runId }),
363
515
  {
364
516
  sessionService: this.options.sessionService,
365
517
  getCredential: this.wire.getCredential?.bind(this.wire),