@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.
- package/CHANGELOG.md +368 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +42 -27
- package/dist/permissions.js +101 -203
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
- package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
- package/dist/wirings/ai-agent/index.d.ts +2 -1
- package/dist/wirings/ai-agent/index.js +2 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +5 -41
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +3 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +4 -12
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -3
- package/package.json +6 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +160 -373
- package/src/permissions.ts +137 -279
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
- package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
- package/src/wirings/ai-agent/index.ts +5 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +9 -40
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +4 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +7 -14
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- 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
|
})
|