@pikku/core 0.12.64 → 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 +221 -0
- package/dist/permissions.d.ts +12 -4
- package/dist/permissions.js +11 -32
- package/dist/testing/service-tests.js +37 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
- package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.js +3 -3
- package/dist/wirings/cli/cli-runner.js +3 -0
- 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.types.d.ts +3 -3
- 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/workflow-dsl.types.d.ts +36 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
- package/dist/wirings/workflow/workflow.types.d.ts +0 -2
- package/package.json +2 -1
- package/src/permissions.test.ts +14 -8
- package/src/permissions.ts +14 -36
- package/src/testing/service-tests.ts +49 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
- package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
- package/src/wirings/ai-agent/index.ts +4 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +8 -10
- package/src/wirings/cli/cli-runner.ts +4 -0
- 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.types.ts +6 -4
- 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/workflow-dsl.types.ts +36 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow.types.ts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -24,6 +24,7 @@ export function validateAndBuildVariableDefinitionsMeta(definitions, schemaLooku
|
|
|
24
24
|
description: def.description,
|
|
25
25
|
variableId: def.variableId,
|
|
26
26
|
schema: def.schema,
|
|
27
|
+
docsUrl: def.docsUrl,
|
|
27
28
|
sourceFile: def.sourceFile,
|
|
28
29
|
};
|
|
29
30
|
}
|
|
@@ -37,6 +38,7 @@ export function validateAndBuildVariableDefinitionsMeta(definitions, schemaLooku
|
|
|
37
38
|
description: def.description,
|
|
38
39
|
variableId: def.variableId,
|
|
39
40
|
schema: def.schema,
|
|
41
|
+
docsUrl: def.docsUrl,
|
|
40
42
|
sourceFile: def.sourceFile,
|
|
41
43
|
};
|
|
42
44
|
}
|
|
@@ -4,6 +4,13 @@ export type CoreVariable<T = unknown> = {
|
|
|
4
4
|
description?: string;
|
|
5
5
|
variableId: string;
|
|
6
6
|
schema: T;
|
|
7
|
+
/**
|
|
8
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
9
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
10
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
11
|
+
* an opaque identifier.
|
|
12
|
+
*/
|
|
13
|
+
docsUrl?: string;
|
|
7
14
|
};
|
|
8
15
|
export type VariableDefinitionMeta = {
|
|
9
16
|
name: string;
|
|
@@ -11,6 +18,13 @@ export type VariableDefinitionMeta = {
|
|
|
11
18
|
description?: string;
|
|
12
19
|
variableId: string;
|
|
13
20
|
schema?: Record<string, unknown> | string;
|
|
21
|
+
/**
|
|
22
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
23
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
24
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
25
|
+
* an opaque identifier.
|
|
26
|
+
*/
|
|
27
|
+
docsUrl?: string;
|
|
14
28
|
sourceFile?: string;
|
|
15
29
|
};
|
|
16
30
|
export type VariableDefinitionsMeta = Record<string, VariableDefinitionMeta>;
|
|
@@ -15,6 +15,13 @@ export interface WorkflowStepOptions {
|
|
|
15
15
|
retries?: number;
|
|
16
16
|
/** Delay between retry attempts (e.g., '1s', '2s', '2min') */
|
|
17
17
|
retryDelay?: string | number;
|
|
18
|
+
/**
|
|
19
|
+
* RPC to invoke for compensation when this step fails after exhausting its
|
|
20
|
+
* retries. Mirrors a graph node's `onError`: the handler receives
|
|
21
|
+
* `{ error: { message } }` and the original error is still thrown, so the
|
|
22
|
+
* workflow fails — this is compensation, not recovery.
|
|
23
|
+
*/
|
|
24
|
+
onError?: string;
|
|
18
25
|
/**
|
|
19
26
|
* Run this step as an actor (scenarios). The RPC is sent through the
|
|
20
27
|
* actor's authenticated client over the REAL transport — never dispatched
|
|
@@ -214,16 +221,20 @@ export interface ParallelGroupStepMeta {
|
|
|
214
221
|
*/
|
|
215
222
|
export interface FanoutStepMeta {
|
|
216
223
|
type: 'fanout';
|
|
217
|
-
/**
|
|
218
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Step name for this fanout. Optional: a fanout is not itself a cached step,
|
|
226
|
+
* and node ids are step names — borrowing a body step's name would give the
|
|
227
|
+
* loop and that step the same id, collapsing one onto the other.
|
|
228
|
+
*/
|
|
229
|
+
stepName?: string;
|
|
219
230
|
/** Source array variable name */
|
|
220
231
|
sourceVar: string;
|
|
221
232
|
/** Iterator variable name */
|
|
222
233
|
itemVar: string;
|
|
223
234
|
/** Execution mode */
|
|
224
235
|
mode: 'parallel' | 'sequential';
|
|
225
|
-
/**
|
|
226
|
-
|
|
236
|
+
/** Steps to execute inline per iteration, in order */
|
|
237
|
+
body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>;
|
|
227
238
|
/** Time between iterations (sequential mode only) */
|
|
228
239
|
timeBetween?: string;
|
|
229
240
|
}
|
|
@@ -234,6 +245,12 @@ export interface ReturnStepMeta {
|
|
|
234
245
|
type: 'return';
|
|
235
246
|
/** Output bindings */
|
|
236
247
|
outputs: Record<string, OutputBinding>;
|
|
248
|
+
/**
|
|
249
|
+
* Variables spread into the returned object (`return { ...r }`), or the sole
|
|
250
|
+
* returned variable (`return r`). Their fields are not enumerable statically,
|
|
251
|
+
* so they are recorded by name rather than expanded into `outputs`.
|
|
252
|
+
*/
|
|
253
|
+
spread?: string[];
|
|
237
254
|
}
|
|
238
255
|
/**
|
|
239
256
|
* Inline step metadata (legacy support)
|
|
@@ -258,6 +275,13 @@ export interface SleepStepMeta {
|
|
|
258
275
|
stepName: string;
|
|
259
276
|
/** Sleep duration */
|
|
260
277
|
duration: string | number;
|
|
278
|
+
/**
|
|
279
|
+
* Source text of a duration only known at runtime (e.g. a loop variable).
|
|
280
|
+
* The closure evaluates it, so it is legal DSL; it is kept separate from
|
|
281
|
+
* `duration` so regenerated code emits it raw rather than as a string
|
|
282
|
+
* literal, exactly as `expression` does on a set step.
|
|
283
|
+
*/
|
|
284
|
+
expression?: string;
|
|
261
285
|
}
|
|
262
286
|
/**
|
|
263
287
|
* Cancel step metadata
|
|
@@ -276,8 +300,14 @@ export interface SetStepMeta {
|
|
|
276
300
|
type: 'set';
|
|
277
301
|
/** Variable name to set (must be in context) */
|
|
278
302
|
variable: string;
|
|
279
|
-
/**
|
|
280
|
-
value
|
|
303
|
+
/** Literal value to assign. Mutually exclusive with `expression`. */
|
|
304
|
+
value?: unknown;
|
|
305
|
+
/**
|
|
306
|
+
* Source text of a non-literal assignment (e.g. `count + 1`). Kept separate
|
|
307
|
+
* from `value` so regenerated code can emit it raw — a string `value` is a
|
|
308
|
+
* string literal, an `expression` is code.
|
|
309
|
+
*/
|
|
310
|
+
expression?: string;
|
|
281
311
|
}
|
|
282
312
|
/**
|
|
283
313
|
* Switch case metadata
|
|
@@ -443,6 +443,14 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
443
443
|
* loop exits immediately without recording a step error or retrying.
|
|
444
444
|
*/
|
|
445
445
|
private runInlineRetryLoop;
|
|
446
|
+
/**
|
|
447
|
+
* Run a failed step's compensation handler as a durable step of its own.
|
|
448
|
+
*
|
|
449
|
+
* Durable rather than a bare invoke so a replay does not compensate twice —
|
|
450
|
+
* a handler is typically a refund or a rollback. `onError` is deliberately
|
|
451
|
+
* not forwarded: a compensation handler cannot itself compensate.
|
|
452
|
+
*/
|
|
453
|
+
private runStepCompensation;
|
|
446
454
|
private rpcStep;
|
|
447
455
|
private inlineStep;
|
|
448
456
|
private sleepStep;
|
|
@@ -1156,6 +1156,16 @@ export class PikkuWorkflowService {
|
|
|
1156
1156
|
}
|
|
1157
1157
|
}
|
|
1158
1158
|
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Run a failed step's compensation handler as a durable step of its own.
|
|
1161
|
+
*
|
|
1162
|
+
* Durable rather than a bare invoke so a replay does not compensate twice —
|
|
1163
|
+
* a handler is typically a refund or a rollback. `onError` is deliberately
|
|
1164
|
+
* not forwarded: a compensation handler cannot itself compensate.
|
|
1165
|
+
*/
|
|
1166
|
+
async runStepCompensation(runId, stepName, onErrorRpcName, rpcService, error) {
|
|
1167
|
+
await this.rpcStep(runId, `${stepName}:onError`, onErrorRpcName, { error: { message: error.message } }, rpcService, { retries: 0 });
|
|
1168
|
+
}
|
|
1159
1169
|
async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
|
|
1160
1170
|
// Capture the predecessor before nextStepKey advances the lineage to us.
|
|
1161
1171
|
const fromStepName = this.lastStepName(runId);
|
|
@@ -1168,6 +1178,7 @@ export class PikkuWorkflowService {
|
|
|
1168
1178
|
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1169
1179
|
retryDelay: stepOptions?.retryDelay,
|
|
1170
1180
|
actor: stepOptions?.actor,
|
|
1181
|
+
onError: stepOptions?.onError,
|
|
1171
1182
|
};
|
|
1172
1183
|
// Check if step already exists
|
|
1173
1184
|
let stepState;
|
|
@@ -1186,6 +1197,11 @@ export class PikkuWorkflowService {
|
|
|
1186
1197
|
// Step failed with retries exhausted - throw error to fail the workflow
|
|
1187
1198
|
const error = new Error(stepState.error?.message ||
|
|
1188
1199
|
`Step '${stepName}' failed after exhausting all retries`);
|
|
1200
|
+
// Compensation, mirroring a graph node's onError: run the handler, then
|
|
1201
|
+
// still throw — the workflow fails either way.
|
|
1202
|
+
if (resolvedStepOptions.onError) {
|
|
1203
|
+
await this.runStepCompensation(runId, stepName, resolvedStepOptions.onError, rpcService, error);
|
|
1204
|
+
}
|
|
1189
1205
|
// Preserve original error properties if available
|
|
1190
1206
|
if (stepState.error) {
|
|
1191
1207
|
Object.assign(error, stepState.error);
|
|
@@ -194,8 +194,6 @@ export type CoreWorkflow<PikkuFunctionConfig extends CorePikkuFunctionConfig<any
|
|
|
194
194
|
func: PikkuFunctionConfig;
|
|
195
195
|
/** Middleware chain for this workflow */
|
|
196
196
|
middleware?: PikkuFunctionConfig['middleware'];
|
|
197
|
-
/** Permission requirements */
|
|
198
|
-
permissions?: PikkuFunctionConfig['permissions'];
|
|
199
197
|
/** Tags for organization and filtering */
|
|
200
198
|
tags?: string[];
|
|
201
199
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.66",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"./scheduler": "./dist/wirings/scheduler/index.js",
|
|
34
34
|
"./trigger": "./dist/wirings/trigger/index.js",
|
|
35
35
|
"./rpc": "./dist/wirings/rpc/index.js",
|
|
36
|
+
"./safe-fetch": "./dist/utils/safe-fetch.js",
|
|
36
37
|
"./mcp": "./dist/wirings/mcp/index.js",
|
|
37
38
|
"./ai-agent": "./dist/wirings/ai-agent/index.js",
|
|
38
39
|
"./gateway": "./dist/wirings/gateway/index.js",
|
package/src/permissions.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
clearPermissionsCache,
|
|
7
7
|
checkAuthPermissions,
|
|
8
8
|
} from './permissions.js'
|
|
9
|
-
import {
|
|
9
|
+
import { resetPikkuState } from './pikku-state.js'
|
|
10
10
|
import { pikkuAuth } from './function/functions.types.js'
|
|
11
11
|
import type { CoreServices, CoreUserSession } from './types/core.types.js'
|
|
12
12
|
import type { CorePermissionGroup } from './function/functions.types.js'
|
|
@@ -190,25 +190,31 @@ describe('checkAuthPermissions', () => {
|
|
|
190
190
|
)
|
|
191
191
|
})
|
|
192
192
|
|
|
193
|
-
test('
|
|
194
|
-
const store = pikkuState(null, 'misc', 'permissions')
|
|
195
|
-
store['isAdmin'] = [pikkuAuth(async () => false)] as any
|
|
193
|
+
test('evaluates a live pikkuAuth predicate from the config group', async () => {
|
|
196
194
|
assert.equal(
|
|
197
195
|
await checkAuthPermissions(
|
|
198
|
-
|
|
196
|
+
{ admin: pikkuAuth(async () => false) as any },
|
|
199
197
|
mockSession,
|
|
200
198
|
mockServices
|
|
201
199
|
),
|
|
202
200
|
false
|
|
203
201
|
)
|
|
202
|
+
assert.equal(
|
|
203
|
+
await checkAuthPermissions(
|
|
204
|
+
{ admin: pikkuAuth(async () => true) as any },
|
|
205
|
+
mockSession,
|
|
206
|
+
mockServices
|
|
207
|
+
),
|
|
208
|
+
true
|
|
209
|
+
)
|
|
204
210
|
})
|
|
205
211
|
|
|
206
212
|
test('ignores data-dependent permissions (no auth marker)', async () => {
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
// A bare pikkuPermission carries no __pikkuAuth brand, so it cannot be
|
|
214
|
+
// evaluated at filter time and must not gate the tool list.
|
|
209
215
|
assert.equal(
|
|
210
216
|
await checkAuthPermissions(
|
|
211
|
-
|
|
217
|
+
{ ownsRow: (async () => false) as any },
|
|
212
218
|
mockSession,
|
|
213
219
|
mockServices
|
|
214
220
|
),
|
package/src/permissions.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CoreServices,
|
|
3
3
|
CoreUserSession,
|
|
4
|
-
PermissionMetadata,
|
|
5
4
|
PikkuWire,
|
|
6
5
|
} from './types/core.types.js'
|
|
7
6
|
import type {
|
|
@@ -60,29 +59,6 @@ const verifyPermissions = async (
|
|
|
60
59
|
return false
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
/**
|
|
64
|
-
* Retrieves a registered permission function by its name.
|
|
65
|
-
*
|
|
66
|
-
* This function looks up permissions that was registered with registerPermission.
|
|
67
|
-
* It's used internally by the framework to resolve permission references in metadata.
|
|
68
|
-
*
|
|
69
|
-
* @param {string} name - The unique name (pikkuFuncId) of the permission function.
|
|
70
|
-
* @param {string | null} packageName - Optional package namespace.
|
|
71
|
-
* @returns {CorePikkuPermission | undefined} The permission function, or undefined if not found.
|
|
72
|
-
*
|
|
73
|
-
* @internal
|
|
74
|
-
*/
|
|
75
|
-
const getPermissionByName = (
|
|
76
|
-
name: string,
|
|
77
|
-
packageName: string | null = null
|
|
78
|
-
): CorePikkuPermission | undefined => {
|
|
79
|
-
const permissionStore = pikkuState(packageName, 'misc', 'permissions')
|
|
80
|
-
const permission = permissionStore[name]
|
|
81
|
-
if (Array.isArray(permission) && permission.length === 1) {
|
|
82
|
-
return permission[0]
|
|
83
|
-
}
|
|
84
|
-
return undefined
|
|
85
|
-
}
|
|
86
62
|
|
|
87
63
|
const globalPermissionsCache: Record<
|
|
88
64
|
string,
|
|
@@ -190,12 +166,15 @@ export const runPermissions = async ({
|
|
|
190
166
|
wire,
|
|
191
167
|
data,
|
|
192
168
|
packageName = null,
|
|
169
|
+
label = 'function',
|
|
193
170
|
}: {
|
|
194
171
|
funcPermissions?: CorePermissionGroup | CorePikkuPermission[]
|
|
195
172
|
services: CoreServices
|
|
196
173
|
wire: PikkuWire<any, never, any, CoreUserSession, never, never, never>
|
|
197
174
|
data: any
|
|
198
175
|
packageName?: string | null
|
|
176
|
+
/** What the non-global gate is called in debug logs, e.g. 'function', 'agent'. */
|
|
177
|
+
label?: string
|
|
199
178
|
}) => {
|
|
200
179
|
const globals = resolveGlobalPermissions(packageName)
|
|
201
180
|
for (const entry of globals) {
|
|
@@ -211,7 +190,7 @@ export const runPermissions = async ({
|
|
|
211
190
|
: funcPermissions
|
|
212
191
|
if (group && Object.keys(group).length > 0) {
|
|
213
192
|
if (!(await verifyPermissions(group, services, data, wire))) {
|
|
214
|
-
services.logger.debug(
|
|
193
|
+
services.logger.debug(`Permission denied - ${label} permission`)
|
|
215
194
|
throw new ForbiddenError('Permission denied')
|
|
216
195
|
}
|
|
217
196
|
}
|
|
@@ -224,14 +203,20 @@ export const runPermissions = async ({
|
|
|
224
203
|
* request data which isn't available at filter time. Global auth requirements
|
|
225
204
|
* are included so a filtered list honours app-wide auth.
|
|
226
205
|
*
|
|
227
|
-
*
|
|
206
|
+
* `funcPermissions` is the live {@link CorePermissionGroup} from the function or
|
|
207
|
+
* agent config, not the metadata form: the `pikkuAuth` brand only survives on
|
|
208
|
+
* the actual predicate objects, and the by-name registry those metadata entries
|
|
209
|
+
* would resolve against is never populated. Passing metadata here would silently
|
|
210
|
+
* collect nothing and let every gated tool through.
|
|
211
|
+
*
|
|
212
|
+
* @param funcPermissions - The live permission group from the func/agent config
|
|
228
213
|
* @param session - The user's session
|
|
229
214
|
* @param services - Singleton services
|
|
230
215
|
* @param packageName - Optional package namespace
|
|
231
216
|
* @returns true if the session passes the auth checks (or no auth checks exist)
|
|
232
217
|
*/
|
|
233
218
|
export const checkAuthPermissions = async (
|
|
234
|
-
funcPermissions:
|
|
219
|
+
funcPermissions: CorePermissionGroup | undefined,
|
|
235
220
|
session: CoreUserSession,
|
|
236
221
|
services: CoreServices,
|
|
237
222
|
packageName: string | null = null
|
|
@@ -271,15 +256,8 @@ export const checkAuthPermissions = async (
|
|
|
271
256
|
collect(entry)
|
|
272
257
|
}
|
|
273
258
|
|
|
274
|
-
if (funcPermissions
|
|
275
|
-
|
|
276
|
-
if (meta.type === 'wire') {
|
|
277
|
-
const permission = getPermissionByName(meta.name, packageName)
|
|
278
|
-
if (permission) {
|
|
279
|
-
collect(permission)
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
259
|
+
if (funcPermissions) {
|
|
260
|
+
collect(funcPermissions)
|
|
283
261
|
}
|
|
284
262
|
|
|
285
263
|
// No auth permissions = allowed (only data-dependent permissions exist)
|
|
@@ -1086,6 +1086,55 @@ export function defineServiceTests(config: ServiceTestConfig): void {
|
|
|
1086
1086
|
assert.ok(Array.isArray(threads))
|
|
1087
1087
|
})
|
|
1088
1088
|
|
|
1089
|
+
if (services.aiStorageService) {
|
|
1090
|
+
const storageFactory = services.aiStorageService
|
|
1091
|
+
|
|
1092
|
+
// The `owners` constraint is what keeps the generated thread-management
|
|
1093
|
+
// functions from leaking across tenants: a caller may only list threads
|
|
1094
|
+
// owned by one of their session principals, matching the
|
|
1095
|
+
// `principal:sub-partition` composition resolveOwnerResourceId writes.
|
|
1096
|
+
test('listThreads scopes to the given owners, including sub-partitions', async () => {
|
|
1097
|
+
const storage = await storageFactory()
|
|
1098
|
+
await storage.createThread('owner-alice')
|
|
1099
|
+
await storage.createThread('owner-alice:project-1')
|
|
1100
|
+
await storage.createThread('owner-bob:secret')
|
|
1101
|
+
|
|
1102
|
+
const threads = await agentService.listThreads({
|
|
1103
|
+
owners: ['owner-alice'],
|
|
1104
|
+
})
|
|
1105
|
+
const ids = threads.map((t) => t.resourceId)
|
|
1106
|
+
|
|
1107
|
+
assert.ok(ids.includes('owner-alice'))
|
|
1108
|
+
assert.ok(ids.includes('owner-alice:project-1'))
|
|
1109
|
+
assert.ok(
|
|
1110
|
+
!ids.some((id) => id.startsWith('owner-bob')),
|
|
1111
|
+
"another owner's threads must not be listed"
|
|
1112
|
+
)
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
test('listThreads with an owner does not match a lookalike prefix', async () => {
|
|
1116
|
+
const storage = await storageFactory()
|
|
1117
|
+
await storage.createThread('owner-al')
|
|
1118
|
+
await storage.createThread('owner-alice-evil:p')
|
|
1119
|
+
|
|
1120
|
+
const threads = await agentService.listThreads({
|
|
1121
|
+
owners: ['owner-al'],
|
|
1122
|
+
})
|
|
1123
|
+
const ids = threads.map((t) => t.resourceId)
|
|
1124
|
+
|
|
1125
|
+
assert.ok(ids.includes('owner-al'))
|
|
1126
|
+
assert.ok(!ids.includes('owner-alice-evil:p'))
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
test('listThreads with an empty owners list returns nothing', async () => {
|
|
1130
|
+
const storage = await storageFactory()
|
|
1131
|
+
await storage.createThread('owner-empty-check')
|
|
1132
|
+
|
|
1133
|
+
const threads = await agentService.listThreads({ owners: [] })
|
|
1134
|
+
assert.deepEqual(threads, [])
|
|
1135
|
+
})
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1089
1138
|
test('getThread returns null for missing', async () => {
|
|
1090
1139
|
const thread = await agentService.getThread('missing-thread')
|
|
1091
1140
|
assert.equal(thread, null)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { beforeEach, describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
resetPikkuState,
|
|
6
|
+
pikkuState,
|
|
7
|
+
setSingletonServices,
|
|
8
|
+
} from '../../pikku-state.js'
|
|
9
|
+
import { assertAgentAuthorized } from './ai-agent-prepare.js'
|
|
10
|
+
import { clearPermissionsCache } from '../../permissions.js'
|
|
11
|
+
import { ForbiddenError, MissingScopeError } from '../../errors/errors.js'
|
|
12
|
+
import type { CoreAIAgent } from './ai-agent.types.js'
|
|
13
|
+
import type { CoreUserSession } from '../../types/core.types.js'
|
|
14
|
+
|
|
15
|
+
const logger = {
|
|
16
|
+
debug: () => {},
|
|
17
|
+
info: () => {},
|
|
18
|
+
warn: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
} as any
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
resetPikkuState()
|
|
24
|
+
clearPermissionsCache()
|
|
25
|
+
setSingletonServices({ logger } as any)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const addAgent = (
|
|
29
|
+
agentName: string,
|
|
30
|
+
overrides: Partial<CoreAIAgent> = {}
|
|
31
|
+
): CoreAIAgent => {
|
|
32
|
+
const agent: CoreAIAgent = {
|
|
33
|
+
name: agentName,
|
|
34
|
+
description: `${agentName} description`,
|
|
35
|
+
goal: `${agentName} goal`,
|
|
36
|
+
instructions: `${agentName} instructions`,
|
|
37
|
+
model: 'test/test-model',
|
|
38
|
+
...overrides,
|
|
39
|
+
} as CoreAIAgent
|
|
40
|
+
|
|
41
|
+
pikkuState(null, 'agent', 'agents').set(agentName, agent)
|
|
42
|
+
return agent
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sessionService = (session: CoreUserSession | undefined) =>
|
|
46
|
+
({
|
|
47
|
+
get: () => session,
|
|
48
|
+
setInitial: () => {},
|
|
49
|
+
sessionChanged: false,
|
|
50
|
+
}) as any
|
|
51
|
+
|
|
52
|
+
describe('assertAgentAuthorized', () => {
|
|
53
|
+
test('resolves when the agent declares no authorization and a session exists', async () => {
|
|
54
|
+
const agent = addAgent('open')
|
|
55
|
+
await assertAgentAuthorized(
|
|
56
|
+
agent,
|
|
57
|
+
{ sessionService: sessionService({ userId: 'u1' }) },
|
|
58
|
+
null
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('does not require a session unless auth is explicitly true', async () => {
|
|
63
|
+
const agent = addAgent('sessionless')
|
|
64
|
+
await assertAgentAuthorized(
|
|
65
|
+
agent,
|
|
66
|
+
{ sessionService: sessionService(undefined) },
|
|
67
|
+
null
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('resolves with no sessionService at all (cron / queue invocation)', async () => {
|
|
72
|
+
const agent = addAgent('no-session-service')
|
|
73
|
+
await assertAgentAuthorized(agent, {}, null)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('throws ForbiddenError when auth is explicitly true and no session exists', async () => {
|
|
77
|
+
const agent = addAgent('needs-session', { auth: true })
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
() =>
|
|
80
|
+
assertAgentAuthorized(
|
|
81
|
+
agent,
|
|
82
|
+
{ sessionService: sessionService(undefined) },
|
|
83
|
+
null
|
|
84
|
+
),
|
|
85
|
+
ForbiddenError
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('enforces scopes even when auth is not required', async () => {
|
|
90
|
+
const agent = addAgent('scoped-sessionless', { scopes: ['reports:read'] })
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
() => assertAgentAuthorized(agent, {}, null),
|
|
93
|
+
MissingScopeError
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('throws ForbiddenError when the permission group denies', async () => {
|
|
98
|
+
const agent = addAgent('guarded', {
|
|
99
|
+
permissions: { admin: async () => false },
|
|
100
|
+
})
|
|
101
|
+
await assert.rejects(
|
|
102
|
+
() =>
|
|
103
|
+
assertAgentAuthorized(
|
|
104
|
+
agent,
|
|
105
|
+
{ sessionService: sessionService({ userId: 'u1' }) },
|
|
106
|
+
null
|
|
107
|
+
),
|
|
108
|
+
ForbiddenError
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('resolves when at least one permission branch passes', async () => {
|
|
113
|
+
const agent = addAgent('or-group', {
|
|
114
|
+
permissions: {
|
|
115
|
+
admin: async () => false,
|
|
116
|
+
owner: async () => true,
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
await assertAgentAuthorized(
|
|
120
|
+
agent,
|
|
121
|
+
{ sessionService: sessionService({ userId: 'u1' }) },
|
|
122
|
+
null
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('passes the session through to permission functions', async () => {
|
|
127
|
+
let seen: CoreUserSession | undefined
|
|
128
|
+
const agent = addAgent('sees-session', {
|
|
129
|
+
permissions: {
|
|
130
|
+
admin: async (_services: any, _data: any, wire: any) => {
|
|
131
|
+
seen = wire.session
|
|
132
|
+
return true
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
await assertAgentAuthorized(
|
|
137
|
+
agent,
|
|
138
|
+
{ sessionService: sessionService({ userId: 'u1' }) },
|
|
139
|
+
null
|
|
140
|
+
)
|
|
141
|
+
assert.equal(seen?.userId, 'u1')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('throws MissingScopeError when a required scope is not held', async () => {
|
|
145
|
+
const agent = addAgent('scoped', { scopes: ['reports:read'] })
|
|
146
|
+
await assert.rejects(
|
|
147
|
+
() =>
|
|
148
|
+
assertAgentAuthorized(
|
|
149
|
+
agent,
|
|
150
|
+
{ sessionService: sessionService({ userId: 'u1', scopes: [] }) },
|
|
151
|
+
null
|
|
152
|
+
),
|
|
153
|
+
MissingScopeError
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('resolves when the session holds the required scope', async () => {
|
|
158
|
+
const agent = addAgent('scoped-ok', { scopes: ['reports:read'] })
|
|
159
|
+
await assertAgentAuthorized(
|
|
160
|
+
agent,
|
|
161
|
+
{
|
|
162
|
+
sessionService: sessionService({
|
|
163
|
+
userId: 'u1',
|
|
164
|
+
scopes: ['reports:read'],
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
null
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('resolves when an ancestor scope grant covers the requirement', async () => {
|
|
172
|
+
const agent = addAgent('scoped-parent', { scopes: ['reports:read'] })
|
|
173
|
+
await assertAgentAuthorized(
|
|
174
|
+
agent,
|
|
175
|
+
{
|
|
176
|
+
sessionService: sessionService({ userId: 'u1', scopes: ['reports'] }),
|
|
177
|
+
},
|
|
178
|
+
null
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('checks scopes before permissions', async () => {
|
|
183
|
+
let permissionRan = false
|
|
184
|
+
const agent = addAgent('scope-first', {
|
|
185
|
+
scopes: ['reports:read'],
|
|
186
|
+
permissions: {
|
|
187
|
+
admin: async () => {
|
|
188
|
+
permissionRan = true
|
|
189
|
+
return true
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
await assert.rejects(
|
|
194
|
+
() =>
|
|
195
|
+
assertAgentAuthorized(
|
|
196
|
+
agent,
|
|
197
|
+
{ sessionService: sessionService({ userId: 'u1', scopes: [] }) },
|
|
198
|
+
null
|
|
199
|
+
),
|
|
200
|
+
MissingScopeError
|
|
201
|
+
)
|
|
202
|
+
assert.equal(permissionRan, false)
|
|
203
|
+
})
|
|
204
|
+
})
|