@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
@@ -3,12 +3,21 @@ import assert from 'node:assert/strict'
3
3
 
4
4
  import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
5
  import {
6
+ agentSessionScope,
7
+ assertResourceOwner,
8
+ canAccessThread,
6
9
  buildInstructions,
7
10
  buildToolDefs,
8
11
  createScopedChannel,
9
12
  getAddonCredentialRequirements,
13
+ isOwnedByPrincipal,
10
14
  resolveAgent,
15
+ resolveOwnerResourceId,
16
+ sessionPrincipals,
17
+ threadOwnerConstraint,
11
18
  } from './ai-agent-prepare.js'
19
+ import { ForbiddenError } from '../../errors/errors.js'
20
+ import { pikkuAuth } from '../../function/functions.types.js'
12
21
  import type {
13
22
  AIStreamChannel,
14
23
  AIStreamEvent,
@@ -124,6 +133,33 @@ describe('ai-agent-prepare', () => {
124
133
  assert.match(instructions, /When calling a sub-agent/)
125
134
  })
126
135
 
136
+ test('buildInstructions joins role, personality and goal in order, blank-line separated', async () => {
137
+ addAgent('scribe', {
138
+ role: 'ROLE',
139
+ personality: 'PERSONALITY',
140
+ goal: 'GOAL',
141
+ })
142
+
143
+ const instructions = await buildInstructions('scribe', null)
144
+ assert.equal(instructions, 'ROLE\n\nPERSONALITY\n\nGOAL')
145
+ })
146
+
147
+ test('buildInstructions omits missing sections rather than leaving blank gaps', async () => {
148
+ addAgent('terse', { role: 'ROLE', goal: 'GOAL' })
149
+
150
+ const instructions = await buildInstructions('terse', null)
151
+ assert.equal(instructions, 'ROLE\n\nGOAL')
152
+ })
153
+
154
+ test('buildInstructions adds no tool or sub-agent guidance when none are configured', async () => {
155
+ addAgent('bare', { role: 'ROLE', goal: undefined })
156
+
157
+ const instructions = await buildInstructions('bare', null)
158
+ assert.equal(instructions, 'ROLE')
159
+ assert.doesNotMatch(instructions, /Tool usage rules:/)
160
+ assert.doesNotMatch(instructions, /When calling a sub-agent/)
161
+ })
162
+
127
163
  test('createScopedChannel forwards stream events, captures approvals, and suppresses done', () => {
128
164
  const events: AIStreamEvent[] = []
129
165
  const channel = createScopedChannel(
@@ -150,6 +186,7 @@ describe('ai-agent-prepare', () => {
150
186
  toolCallId: 'tc-1',
151
187
  toolName: 'tool-a',
152
188
  args: { x: 1 },
189
+ reason: 'Delete todo "x"',
153
190
  runId: 'run-1',
154
191
  } as AIStreamEvent)
155
192
  channel.send({ type: 'done' } as AIStreamEvent)
@@ -161,6 +198,7 @@ describe('ai-agent-prepare', () => {
161
198
  toolCallId: 'tc-1',
162
199
  toolName: 'tool-a',
163
200
  args: { x: 1 },
201
+ reason: 'Delete todo "x"',
164
202
  runId: 'run-1',
165
203
  },
166
204
  ])
@@ -237,6 +275,106 @@ describe('ai-agent-prepare', () => {
237
275
  assert.equal(afterCalls.length, 1)
238
276
  })
239
277
 
278
+ test('buildToolDefs resolves addon-scoped services for approvalDescription on a cold services cache', async () => {
279
+ addAgent('todo-agent')
280
+ pikkuState(null, 'agent', 'agentsMeta')['todo-agent'] = {
281
+ ...pikkuState(null, 'agent', 'agentsMeta')['todo-agent'],
282
+ tools: ['todos:deleteTodo'],
283
+ } as any
284
+ pikkuState(null, 'addons', 'packages').set('todos', {
285
+ package: '@test/todos-addon',
286
+ })
287
+ pikkuState('@test/todos-addon', 'function', 'meta').deleteTodo = {
288
+ description: 'Deletes a todo by ID',
289
+ approvalRequired: true,
290
+ inputSchemaName: 'DeleteTodoInput',
291
+ sessionless: true,
292
+ } as any
293
+ pikkuState('@test/todos-addon', 'misc', 'schemas').set('DeleteTodoInput', {
294
+ type: 'object',
295
+ })
296
+ pikkuState('@test/todos-addon', 'function', 'functions').set('deleteTodo', {
297
+ func: async ({ todoStore }: any, { id }: any) => todoStore.delete(id),
298
+ approvalDescription: async ({ todoStore }: any, { id }: any) =>
299
+ `Delete the todo called "${todoStore.get(id)?.title ?? id}"`,
300
+ })
301
+
302
+ // The addon builds `todoStore` in its own singleton services, which are NOT
303
+ // in the root services and are only cached the first time the addon runs.
304
+ // The approval is raised before the tool executes, so the cache is cold.
305
+ pikkuState('@test/todos-addon', 'package', 'factories', {
306
+ createSingletonServices: async () => ({
307
+ todoStore: {
308
+ get: (id: string) =>
309
+ id === '1' ? { id: '1', title: 'Buy groceries' } : undefined,
310
+ delete: () => true,
311
+ },
312
+ }),
313
+ } as any)
314
+
315
+ const singletonServices = {
316
+ logger: { warn: () => {} },
317
+ } as any
318
+ pikkuState(null, 'package', 'singletonServices', singletonServices)
319
+
320
+ const { tools } = await buildToolDefs(
321
+ {},
322
+ new Map<string, string>(),
323
+ 'resource-1',
324
+ 'todo-agent',
325
+ null
326
+ )
327
+
328
+ assert.equal(tools.length, 1)
329
+ assert.equal(tools[0].needsApproval, true)
330
+ assert.equal(
331
+ await tools[0].approvalDescriptionFn?.({ id: '1' }),
332
+ 'Delete the todo called "Buy groceries"'
333
+ )
334
+ })
335
+
336
+ test('buildToolDefs logs a tool execute() failure even without aiMiddleware hooks, then rethrows', async () => {
337
+ addAgent('ops-agent')
338
+ pikkuState(null, 'agent', 'agentsMeta')['ops-agent'] = {
339
+ ...pikkuState(null, 'agent', 'agentsMeta')['ops-agent'],
340
+ tools: ['searchInventory'],
341
+ } as any
342
+ pikkuState(null, 'rpc', 'meta').searchInventory = 'searchInventory'
343
+ pikkuState(null, 'function', 'meta').searchInventory = {
344
+ description: 'Search inventory',
345
+ inputSchemaName: 'SearchInput',
346
+ sessionless: true,
347
+ }
348
+ pikkuState(null, 'misc', 'schemas').set('SearchInput', { type: 'object' })
349
+ pikkuState(null, 'function', 'functions').set('searchInventory', {
350
+ func: async () => {
351
+ throw new Error('db unreachable')
352
+ },
353
+ })
354
+
355
+ const errorCalls: unknown[][] = []
356
+ const singletonServices = {
357
+ logger: {
358
+ warn: () => {},
359
+ error: (...args: unknown[]) => errorCalls.push(args),
360
+ },
361
+ } as any
362
+ pikkuState(null, 'package', 'singletonServices', singletonServices)
363
+
364
+ const { tools } = await buildToolDefs(
365
+ {},
366
+ new Map<string, string>(),
367
+ 'resource-1',
368
+ 'ops-agent',
369
+ null
370
+ )
371
+
372
+ assert.equal(tools.length, 1)
373
+ await assert.rejects(() => tools[0].execute({}), /db unreachable/)
374
+ assert.equal(errorCalls.length, 1)
375
+ assert.match(String(errorCalls[0][0]), /searchInventory.*execute/)
376
+ })
377
+
240
378
  test('buildToolDefs skips permissioned tools without a session and adds sub-agent tools', async () => {
241
379
  addAgent('manager', {
242
380
  agents: ['assistant'],
@@ -291,4 +429,349 @@ describe('ai-agent-prepare', () => {
291
429
  required: ['message', 'session'],
292
430
  })
293
431
  })
432
+
433
+ /**
434
+ * The permission's `pikkuAuth` brand survives only on the live objects in the
435
+ * function config, not on the metadata. Reading the metadata's by-name
436
+ * reference resolves against a registry nothing populates, so it collects no
437
+ * predicate and lets every gated tool through — this pins that the live config
438
+ * is what gets evaluated.
439
+ */
440
+ const addFilterAgent = (allow: boolean) => {
441
+ addAgent('gatekeeper', { tools: ['gated'] })
442
+ pikkuState(null, 'agent', 'agentsMeta').gatekeeper = {
443
+ ...pikkuState(null, 'agent', 'agentsMeta').gatekeeper,
444
+ tools: ['gated'],
445
+ } as any
446
+ pikkuState(null, 'rpc', 'meta').gated = 'gated'
447
+ pikkuState(null, 'function', 'meta').gated = {
448
+ description: 'Gated tool',
449
+ permissions: [{ type: 'wire', name: 'mayUse' }],
450
+ inputSchemaName: 'GatedInput',
451
+ sessionless: true,
452
+ } as any
453
+ pikkuState(null, 'misc', 'schemas').set('GatedInput', {
454
+ type: 'object',
455
+ properties: {},
456
+ })
457
+ pikkuState(null, 'function', 'functions').set('gated', {
458
+ func: async () => ({ ok: true }),
459
+ permissions: { mayUse: pikkuAuth(async () => allow) },
460
+ } as any)
461
+ pikkuState(null, 'package', 'singletonServices', {
462
+ logger: { warn: () => {}, debug: () => {} },
463
+ } as any)
464
+ }
465
+
466
+ const sessionParams = () =>
467
+ ({
468
+ sessionService: { get: async () => ({ userId: 'u1' }) },
469
+ }) as any
470
+
471
+ test('buildToolDefs offers a tool whose live auth predicate passes', async () => {
472
+ addFilterAgent(true)
473
+ const { tools } = await buildToolDefs(
474
+ sessionParams(),
475
+ new Map<string, string>(),
476
+ 'resource-1',
477
+ 'gatekeeper',
478
+ null
479
+ )
480
+ assert.deepEqual(
481
+ tools.map((t) => t.name),
482
+ ['gated']
483
+ )
484
+ })
485
+
486
+ test('buildToolDefs filters out a tool whose live auth predicate fails', async () => {
487
+ addFilterAgent(false)
488
+ const { tools } = await buildToolDefs(
489
+ sessionParams(),
490
+ new Map<string, string>(),
491
+ 'resource-1',
492
+ 'gatekeeper',
493
+ null
494
+ )
495
+ assert.deepEqual(tools, [])
496
+ })
497
+
498
+ test('buildToolDefs adds a workflow tool that runs the workflow and returns its output', async () => {
499
+ addAgent('planner', { workflows: ['buildReport'] } as any)
500
+ pikkuState(null, 'agent', 'agentsMeta').planner = {
501
+ ...pikkuState(null, 'agent', 'agentsMeta').planner,
502
+ workflows: ['buildReport'],
503
+ } as any
504
+
505
+ pikkuState(null, 'workflows', 'meta').buildReport = {
506
+ name: 'buildReport',
507
+ pikkuFuncId: 'buildReport',
508
+ source: 'graph',
509
+ description: 'Build a report for a country',
510
+ } as any
511
+ pikkuState(null, 'function', 'meta').buildReport = {
512
+ inputSchemaName: 'BuildReportInput',
513
+ sessionless: true,
514
+ } as any
515
+ pikkuState(null, 'misc', 'schemas').set('BuildReportInput', {
516
+ type: 'object',
517
+ properties: { country: { type: 'string' } },
518
+ required: ['country'],
519
+ })
520
+
521
+ const runCalls: Array<{ name: string; input: unknown }> = []
522
+ const workflowService = {
523
+ runToCompletion: async (name: string, input: unknown) => {
524
+ runCalls.push({ name, input })
525
+ return { capital: 'Paris' }
526
+ },
527
+ }
528
+ const singletonServices = {
529
+ logger: { warn: () => {} },
530
+ workflowService,
531
+ } as any
532
+ pikkuState(null, 'package', 'singletonServices', singletonServices)
533
+
534
+ const { tools, missingRpcs } = await buildToolDefs(
535
+ {},
536
+ new Map<string, string>(),
537
+ 'resource-1',
538
+ 'planner',
539
+ null
540
+ )
541
+
542
+ assert.deepEqual(missingRpcs, [])
543
+ assert.equal(tools.length, 1)
544
+ assert.equal(tools[0].name, 'buildReport')
545
+ assert.equal(tools[0].description, 'Build a report for a country')
546
+ assert.deepEqual(tools[0].inputSchema, {
547
+ type: 'object',
548
+ properties: { country: { type: 'string' } },
549
+ required: ['country'],
550
+ })
551
+
552
+ const result = await tools[0].execute({ country: 'France' })
553
+ assert.deepEqual(result, { capital: 'Paris' })
554
+ assert.deepEqual(runCalls, [
555
+ { name: 'buildReport', input: { country: 'France' } },
556
+ ])
557
+ })
558
+
559
+ test('buildToolDefs reports an unknown workflow tool as missing', async () => {
560
+ addAgent('planner2', { workflows: ['ghost'] } as any)
561
+ pikkuState(null, 'agent', 'agentsMeta').planner2 = {
562
+ ...pikkuState(null, 'agent', 'agentsMeta').planner2,
563
+ workflows: ['ghost'],
564
+ } as any
565
+ pikkuState(null, 'package', 'singletonServices', {
566
+ logger: { warn: () => {} },
567
+ workflowService: { runToCompletion: async () => ({}) },
568
+ } as any)
569
+
570
+ const { tools, missingRpcs } = await buildToolDefs(
571
+ {},
572
+ new Map<string, string>(),
573
+ 'resource-1',
574
+ 'planner2',
575
+ null
576
+ )
577
+
578
+ assert.equal(tools.length, 0)
579
+ assert.deepEqual(missingRpcs, ['ghost'])
580
+ })
581
+ })
582
+
583
+ describe('C2 thread/run ownership + sessionScope', () => {
584
+ const params = (session?: Record<string, unknown>) => ({
585
+ sessionService: { get: () => session } as never,
586
+ })
587
+
588
+ test('user scope composes the trusted userId with the requested resourceId', () => {
589
+ assert.equal(
590
+ resolveOwnerResourceId(params({ userId: 'user-a' }), 'user', 'default'),
591
+ 'user-a:default'
592
+ )
593
+ })
594
+
595
+ test('user scope is the default when sessionScope is undefined', () => {
596
+ assert.equal(
597
+ resolveOwnerResourceId(params({ userId: 'user-a' }), undefined, 'p1'),
598
+ 'user-a:p1'
599
+ )
600
+ })
601
+
602
+ test('user scope sub-partitions within the owner (client resourceId regains meaning)', () => {
603
+ assert.equal(
604
+ resolveOwnerResourceId(params({ userId: 'alice' }), 'user', 'project-1'),
605
+ 'alice:project-1'
606
+ )
607
+ assert.equal(
608
+ resolveOwnerResourceId(params({ userId: 'alice' }), 'user', 'project-2'),
609
+ 'alice:project-2'
610
+ )
611
+ })
612
+
613
+ test('is idempotent: re-normalizing an already-owned key does not double-compose', () => {
614
+ assert.equal(
615
+ resolveOwnerResourceId(
616
+ params({ userId: 'alice' }),
617
+ 'user',
618
+ 'alice:project-1'
619
+ ),
620
+ 'alice:project-1'
621
+ )
622
+ })
623
+
624
+ test('a client cannot forge another owner: a foreign prefix is re-scoped, not trusted', () => {
625
+ assert.equal(
626
+ resolveOwnerResourceId(params({ userId: 'bob' }), 'user', 'alice:secret'),
627
+ 'bob:alice:secret'
628
+ )
629
+ })
630
+
631
+ test('user scope falls back to the bare requested resourceId when sessionless', () => {
632
+ assert.equal(resolveOwnerResourceId({}, 'user', 'resource-1'), 'resource-1')
633
+ assert.equal(
634
+ resolveOwnerResourceId(params(undefined), 'user', 'resource-1'),
635
+ 'resource-1'
636
+ )
637
+ })
638
+
639
+ test('org scope composes the trusted orgId with the requested resourceId', () => {
640
+ assert.equal(
641
+ resolveOwnerResourceId(
642
+ params({ userId: 'user-a', orgId: 'org-x' }),
643
+ 'org',
644
+ 'default'
645
+ ),
646
+ 'org-x:default'
647
+ )
648
+ })
649
+
650
+ test('org scope denies (ForbiddenError) when the session has no org', () => {
651
+ assert.throws(
652
+ () =>
653
+ resolveOwnerResourceId(params({ userId: 'user-a' }), 'org', 'default'),
654
+ (e: unknown) => e instanceof ForbiddenError
655
+ )
656
+ assert.throws(
657
+ () => resolveOwnerResourceId({}, 'org', 'default'),
658
+ (e: unknown) => e instanceof ForbiddenError
659
+ )
660
+ })
661
+
662
+ test('assertResourceOwner throws ForbiddenError on an owner mismatch', () => {
663
+ assert.throws(
664
+ () => assertResourceOwner('user-a:d', 'user-b:d', 'thread'),
665
+ (e: unknown) =>
666
+ e instanceof ForbiddenError && !/user-a|user-b/.test(e.message)
667
+ )
668
+ assert.doesNotThrow(() =>
669
+ assertResourceOwner('user-a:d', 'user-a:d', 'run')
670
+ )
671
+ })
672
+ })
673
+
674
+ describe('thread-read ownership (session principals)', () => {
675
+ test('a principal owns its own bare key and any sub-partition of it', () => {
676
+ assert.equal(isOwnedByPrincipal('alice', 'alice'), true)
677
+ assert.equal(isOwnedByPrincipal('alice:project-1', 'alice'), true)
678
+ })
679
+
680
+ test('a principal does not own a lookalike prefix', () => {
681
+ assert.equal(isOwnedByPrincipal('alice-evil:p', 'alice'), false)
682
+ assert.equal(isOwnedByPrincipal('bob:alice:secret', 'alice'), false)
683
+ })
684
+
685
+ test('the caller may read a thread owned by their userId', () => {
686
+ assert.equal(canAccessThread('alice:default', { userId: 'alice' }), true)
687
+ })
688
+
689
+ test('the caller may read a thread owned by their orgId', () => {
690
+ assert.equal(
691
+ canAccessThread('org-x:default', { userId: 'alice', orgId: 'org-x' }),
692
+ true
693
+ )
694
+ })
695
+
696
+ test('cross-user thread reads are refused', () => {
697
+ assert.equal(canAccessThread('bob:secret', { userId: 'alice' }), false)
698
+ })
699
+
700
+ test('cross-org thread reads are refused', () => {
701
+ assert.equal(
702
+ canAccessThread('org-y:d', { userId: 'alice', orgId: 'org-x' }),
703
+ false
704
+ )
705
+ })
706
+
707
+ test('a forged owner prefix in the stored id cannot grant access', () => {
708
+ assert.equal(
709
+ canAccessThread('bob:alice:secret', { userId: 'alice' }),
710
+ false
711
+ )
712
+ })
713
+
714
+ // A deployment with no session has explicitly opted out of authorization
715
+ // (agent `no-auth`), so there is no ownership model to enforce — matching
716
+ // resolveOwnerResourceId's own sessionless fallback to a bare resourceId.
717
+ test('a sessionless deployment has no ownership model and is not gated', () => {
718
+ assert.equal(canAccessThread('anything', undefined), true)
719
+ assert.equal(canAccessThread('anything', {}), true)
720
+ })
721
+
722
+ test('sessionPrincipals lists the userId and orgId a caller can read as', () => {
723
+ assert.deepEqual(sessionPrincipals({ userId: 'alice', orgId: 'org-x' }), [
724
+ 'alice',
725
+ 'org-x',
726
+ ])
727
+ assert.deepEqual(sessionPrincipals({ userId: 'alice' }), ['alice'])
728
+ assert.deepEqual(sessionPrincipals(undefined), [])
729
+ })
730
+
731
+ test('threadOwnerConstraint scopes a session to its principals', () => {
732
+ assert.deepEqual(
733
+ threadOwnerConstraint({ userId: 'alice', orgId: 'org-x' }),
734
+ ['alice', 'org-x']
735
+ )
736
+ })
737
+
738
+ // undefined, not [] — [] means "match nothing", which would hide every thread
739
+ // from a no-auth deployment that never had an ownership model to begin with.
740
+ test('threadOwnerConstraint is undefined for a sessionless caller', () => {
741
+ assert.equal(threadOwnerConstraint(undefined), undefined)
742
+ assert.equal(threadOwnerConstraint({}), undefined)
743
+ })
744
+ })
745
+
746
+ describe('C2 sessionScope + resume ownership', () => {
747
+ const params = (session?: Record<string, unknown>) => ({
748
+ sessionService: { get: () => session } as never,
749
+ })
750
+
751
+ test('agentSessionScope reads the declared scope and defaults to user', () => {
752
+ addAgent('scoped-user')
753
+ addAgent('scoped-org', { sessionScope: 'org' })
754
+ assert.equal(agentSessionScope('scoped-user'), 'user')
755
+ assert.equal(agentSessionScope('scoped-org'), 'org')
756
+ })
757
+
758
+ test('resume ownership: owner passes, a different user is rejected (via idempotent recompose)', () => {
759
+ const stored = 'alice:default'
760
+ assert.doesNotThrow(() =>
761
+ assertResourceOwner(
762
+ resolveOwnerResourceId(params({ userId: 'alice' }), 'user', stored),
763
+ stored,
764
+ 'run'
765
+ )
766
+ )
767
+ assert.throws(
768
+ () =>
769
+ assertResourceOwner(
770
+ resolveOwnerResourceId(params({ userId: 'bob' }), 'user', stored),
771
+ stored,
772
+ 'run'
773
+ ),
774
+ (e: unknown) => e instanceof ForbiddenError
775
+ )
776
+ })
294
777
  })