@pikku/core 0.12.64 → 0.12.67
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 +311 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/permissions.d.ts +12 -4
- package/dist/permissions.js +11 -32
- package/dist/scopes.d.ts +14 -0
- package/dist/scopes.js +39 -8
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +22 -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/scope/validate-scope-definitions.d.ts +8 -0
- package/dist/wirings/scope/validate-scope-definitions.js +16 -1
- 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/index.ts +2 -1
- package/src/permissions.test.ts +14 -8
- package/src/permissions.ts +14 -36
- package/src/scopes.test.ts +37 -1
- package/src/scopes.ts +48 -9
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +23 -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/scope/scope.test.ts +25 -0
- package/src/wirings/scope/validate-scope-definitions.ts +16 -1
- 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
|
@@ -34,6 +34,49 @@ export declare function agentSessionScope(agentName: string): SessionScope;
|
|
|
34
34
|
* existence oracle) on a mismatch.
|
|
35
35
|
*/
|
|
36
36
|
export declare function assertResourceOwner(ownerResourceId: string, storedResourceId: string, kind: 'thread' | 'run'): void;
|
|
37
|
+
/** A session's trusted principals, in the order they may be read as. */
|
|
38
|
+
export declare function sessionPrincipals(session: {
|
|
39
|
+
userId?: string;
|
|
40
|
+
orgId?: string;
|
|
41
|
+
} | undefined): string[];
|
|
42
|
+
/**
|
|
43
|
+
* Whether `storedResourceId` belongs to `principal` under the composition
|
|
44
|
+
* {@link resolveOwnerResourceId} performs — either the bare principal or one of
|
|
45
|
+
* its `principal:` sub-partitions. The `:` is required so that `alice` does not
|
|
46
|
+
* match a `alice-evil:…` lookalike.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isOwnedByPrincipal(storedResourceId: string, principal: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The `owners` constraint to pass to `AgentRunService.listThreads` for a caller.
|
|
51
|
+
*
|
|
52
|
+
* Returns `undefined` — not `[]` — for a session with no principal, because an
|
|
53
|
+
* empty list means "match nothing" whereas a sessionless deployment (agent
|
|
54
|
+
* `no-auth`) has no ownership model to constrain by. Keeping that carve-out here
|
|
55
|
+
* rather than at each call site stops it from being re-derived inconsistently.
|
|
56
|
+
*/
|
|
57
|
+
export declare function threadOwnerConstraint(session: {
|
|
58
|
+
userId?: string;
|
|
59
|
+
orgId?: string;
|
|
60
|
+
} | undefined): string[] | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Whether a stored thread/run may be read by the caller. Shaped as a predicate
|
|
63
|
+
* so it can back a `pikkuPermission` — authorization belongs in a function's
|
|
64
|
+
* `permissions` field, never in its body.
|
|
65
|
+
*
|
|
66
|
+
* Unlike {@link assertResourceOwner}, which compares against a single composed
|
|
67
|
+
* owner key on the run path, this guards the thread-management reads where the
|
|
68
|
+
* caller supplies only a `threadId` — so ownership has to be derived from the
|
|
69
|
+
* session rather than from the request.
|
|
70
|
+
*
|
|
71
|
+
* A session with no principal means the deployment opted out of authorization
|
|
72
|
+
* (agent `no-auth`), so there is no ownership model to enforce and access is not
|
|
73
|
+
* gated — mirroring {@link resolveOwnerResourceId}'s sessionless fallback to a
|
|
74
|
+
* bare resourceId.
|
|
75
|
+
*/
|
|
76
|
+
export declare function canAccessThread(storedResourceId: string, session: {
|
|
77
|
+
userId?: string;
|
|
78
|
+
orgId?: string;
|
|
79
|
+
} | undefined): boolean;
|
|
37
80
|
export type StreamAIAgentOptions = {
|
|
38
81
|
requiresToolApproval?: 'all' | 'explicit' | false;
|
|
39
82
|
/**
|
|
@@ -93,6 +136,27 @@ export declare const resolveAgent: (agentName: string) => {
|
|
|
93
136
|
packageName: string | null;
|
|
94
137
|
resolvedName: string;
|
|
95
138
|
};
|
|
139
|
+
/**
|
|
140
|
+
* Enforces an agent's own authorization before it runs: session presence
|
|
141
|
+
* (`auth`), then `scopes`, then `permissions`.
|
|
142
|
+
*
|
|
143
|
+
* The ordering mirrors the function runner — scopes are an AND gate checked
|
|
144
|
+
* first, so they can only ever narrow access, and a missing scope short-circuits
|
|
145
|
+
* before any permission function does I/O.
|
|
146
|
+
*
|
|
147
|
+
* `auth` follows `pikkuSessionlessFunc` rather than `pikkuFunc`: a session is
|
|
148
|
+
* required only when `auth: true` is set explicitly. An agent is normally
|
|
149
|
+
* reached from a function that has already enforced its own auth, and agents are
|
|
150
|
+
* also run from genuinely sessionless contexts (crons, queue workers), so
|
|
151
|
+
* requiring a session by default would reject those without adding a meaningful
|
|
152
|
+
* gate. `scopes` and `permissions` are always enforced when declared.
|
|
153
|
+
*
|
|
154
|
+
* Globals are evaluated here too (via {@link runPermissions}) rather than being
|
|
155
|
+
* assumed to have already run: an agent is reachable from entry points that do
|
|
156
|
+
* not go through the function runner, and re-evaluating an AND gate of
|
|
157
|
+
* side-effect-free predicates is idempotent.
|
|
158
|
+
*/
|
|
159
|
+
export declare function assertAgentAuthorized(agent: CoreAIAgent, params: RunAIAgentParams, packageName: string | null): Promise<void>;
|
|
96
160
|
export declare function buildInstructions(agentName: string, packageName: string | null): Promise<string>;
|
|
97
161
|
export type ScopedChannel = AIStreamChannel & {
|
|
98
162
|
approvals: Array<{
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { PikkuError } from '../../errors/error-handler.js';
|
|
2
|
-
import { checkAuthPermissions } from '../../permissions.js';
|
|
2
|
+
import { checkAuthPermissions, runPermissions } from '../../permissions.js';
|
|
3
3
|
import { AIProviderNotConfiguredError } from '../../errors/errors.js';
|
|
4
4
|
import { ForbiddenError } from '../../errors/errors.js';
|
|
5
|
+
import { verifyScopes } from '../../scopes.js';
|
|
5
6
|
import { pikkuState, getSingletonServices } from '../../pikku-state.js';
|
|
6
7
|
import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js';
|
|
7
8
|
import { randomUUID } from './ai-agent-utils.js';
|
|
@@ -62,6 +63,53 @@ export function assertResourceOwner(ownerResourceId, storedResourceId, kind) {
|
|
|
62
63
|
throw new ForbiddenError(`Not authorized to access this ${kind}`);
|
|
63
64
|
}
|
|
64
65
|
}
|
|
66
|
+
/** A session's trusted principals, in the order they may be read as. */
|
|
67
|
+
export function sessionPrincipals(session) {
|
|
68
|
+
return [session?.userId, session?.orgId].filter((principal) => Boolean(principal));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether `storedResourceId` belongs to `principal` under the composition
|
|
72
|
+
* {@link resolveOwnerResourceId} performs — either the bare principal or one of
|
|
73
|
+
* its `principal:` sub-partitions. The `:` is required so that `alice` does not
|
|
74
|
+
* match a `alice-evil:…` lookalike.
|
|
75
|
+
*/
|
|
76
|
+
export function isOwnedByPrincipal(storedResourceId, principal) {
|
|
77
|
+
return (storedResourceId === principal ||
|
|
78
|
+
storedResourceId.startsWith(`${principal}:`));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The `owners` constraint to pass to `AgentRunService.listThreads` for a caller.
|
|
82
|
+
*
|
|
83
|
+
* Returns `undefined` — not `[]` — for a session with no principal, because an
|
|
84
|
+
* empty list means "match nothing" whereas a sessionless deployment (agent
|
|
85
|
+
* `no-auth`) has no ownership model to constrain by. Keeping that carve-out here
|
|
86
|
+
* rather than at each call site stops it from being re-derived inconsistently.
|
|
87
|
+
*/
|
|
88
|
+
export function threadOwnerConstraint(session) {
|
|
89
|
+
const principals = sessionPrincipals(session);
|
|
90
|
+
return principals.length > 0 ? principals : undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Whether a stored thread/run may be read by the caller. Shaped as a predicate
|
|
94
|
+
* so it can back a `pikkuPermission` — authorization belongs in a function's
|
|
95
|
+
* `permissions` field, never in its body.
|
|
96
|
+
*
|
|
97
|
+
* Unlike {@link assertResourceOwner}, which compares against a single composed
|
|
98
|
+
* owner key on the run path, this guards the thread-management reads where the
|
|
99
|
+
* caller supplies only a `threadId` — so ownership has to be derived from the
|
|
100
|
+
* session rather than from the request.
|
|
101
|
+
*
|
|
102
|
+
* A session with no principal means the deployment opted out of authorization
|
|
103
|
+
* (agent `no-auth`), so there is no ownership model to enforce and access is not
|
|
104
|
+
* gated — mirroring {@link resolveOwnerResourceId}'s sessionless fallback to a
|
|
105
|
+
* bare resourceId.
|
|
106
|
+
*/
|
|
107
|
+
export function canAccessThread(storedResourceId, session) {
|
|
108
|
+
const principals = sessionPrincipals(session);
|
|
109
|
+
if (principals.length === 0)
|
|
110
|
+
return true;
|
|
111
|
+
return principals.some((principal) => isOwnedByPrincipal(storedResourceId, principal));
|
|
112
|
+
}
|
|
65
113
|
/**
|
|
66
114
|
* Non-forgeable brand for the sub-agent approval marker. Only framework code
|
|
67
115
|
* (the delegating sub-agent tools below) sets this Symbol on a tool result; a
|
|
@@ -163,6 +211,47 @@ export const resolveAgent = (agentName) => {
|
|
|
163
211
|
}
|
|
164
212
|
throw new Error(`AI agent not found: ${agentName}`);
|
|
165
213
|
};
|
|
214
|
+
/**
|
|
215
|
+
* Enforces an agent's own authorization before it runs: session presence
|
|
216
|
+
* (`auth`), then `scopes`, then `permissions`.
|
|
217
|
+
*
|
|
218
|
+
* The ordering mirrors the function runner — scopes are an AND gate checked
|
|
219
|
+
* first, so they can only ever narrow access, and a missing scope short-circuits
|
|
220
|
+
* before any permission function does I/O.
|
|
221
|
+
*
|
|
222
|
+
* `auth` follows `pikkuSessionlessFunc` rather than `pikkuFunc`: a session is
|
|
223
|
+
* required only when `auth: true` is set explicitly. An agent is normally
|
|
224
|
+
* reached from a function that has already enforced its own auth, and agents are
|
|
225
|
+
* also run from genuinely sessionless contexts (crons, queue workers), so
|
|
226
|
+
* requiring a session by default would reject those without adding a meaningful
|
|
227
|
+
* gate. `scopes` and `permissions` are always enforced when declared.
|
|
228
|
+
*
|
|
229
|
+
* Globals are evaluated here too (via {@link runPermissions}) rather than being
|
|
230
|
+
* assumed to have already run: an agent is reachable from entry points that do
|
|
231
|
+
* not go through the function runner, and re-evaluating an AND gate of
|
|
232
|
+
* side-effect-free predicates is idempotent.
|
|
233
|
+
*/
|
|
234
|
+
export async function assertAgentAuthorized(agent, params, packageName) {
|
|
235
|
+
const session = params.sessionService
|
|
236
|
+
? await params.sessionService.get()
|
|
237
|
+
: undefined;
|
|
238
|
+
if (agent.auth === true && !session) {
|
|
239
|
+
throw new ForbiddenError('Authentication required');
|
|
240
|
+
}
|
|
241
|
+
verifyScopes(agent.scopes, session);
|
|
242
|
+
const singletonServices = getSingletonServices();
|
|
243
|
+
const wire = params.sessionService
|
|
244
|
+
? createMiddlewareSessionWireProps(params.sessionService)
|
|
245
|
+
: { session: undefined };
|
|
246
|
+
await runPermissions({
|
|
247
|
+
funcPermissions: agent.permissions,
|
|
248
|
+
services: singletonServices,
|
|
249
|
+
wire: wire,
|
|
250
|
+
data: {},
|
|
251
|
+
packageName,
|
|
252
|
+
label: 'agent',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
166
255
|
export async function buildInstructions(agentName, packageName) {
|
|
167
256
|
const meta = pikkuState(packageName, 'agent', 'agentsMeta')[agentName];
|
|
168
257
|
const parts = [];
|
|
@@ -279,11 +368,17 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
|
|
|
279
368
|
missingRpcs.push(toolName);
|
|
280
369
|
continue;
|
|
281
370
|
}
|
|
282
|
-
// Filter out tools the user doesn't have auth for
|
|
371
|
+
// Filter out tools the user doesn't have auth for. The `pikkuAuth` brand
|
|
372
|
+
// only survives on the live permission objects in the function config, so
|
|
373
|
+
// the check reads those rather than the metadata (whose by-name registry
|
|
374
|
+
// is never populated, which would let every gated tool through).
|
|
283
375
|
if (fnMeta.permissions?.length) {
|
|
284
376
|
if (!session)
|
|
285
377
|
continue;
|
|
286
|
-
const
|
|
378
|
+
const funcConfig = pikkuFuncId
|
|
379
|
+
? pikkuState(resolvedPkg, 'function', 'functions').get(pikkuFuncId)
|
|
380
|
+
: undefined;
|
|
381
|
+
const allowed = await checkAuthPermissions(funcConfig?.permissions, session, singletonServices, resolvedPkg);
|
|
287
382
|
if (!allowed)
|
|
288
383
|
continue;
|
|
289
384
|
}
|
|
@@ -351,11 +446,13 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
|
|
|
351
446
|
singletonServices.logger.warn(`Sub-agent '${subAgentName}' not found in agent registry`);
|
|
352
447
|
continue;
|
|
353
448
|
}
|
|
354
|
-
// Filter out sub-agents the user doesn't have auth for
|
|
449
|
+
// Filter out sub-agents the user doesn't have auth for, reading the live
|
|
450
|
+
// agent config for the same reason the tool path does.
|
|
355
451
|
if (subMeta.permissions?.length) {
|
|
356
452
|
if (!session)
|
|
357
453
|
continue;
|
|
358
|
-
const
|
|
454
|
+
const subAgent = pikkuState(null, 'agent', 'agents').get(subAgentName);
|
|
455
|
+
const allowed = await checkAuthPermissions(subAgent?.permissions, session, singletonServices);
|
|
359
456
|
if (!allowed)
|
|
360
457
|
continue;
|
|
361
458
|
}
|
|
@@ -567,6 +664,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
|
|
|
567
664
|
export async function prepareAgentRun(agentName, input, params, agentSessionMap, streamContext) {
|
|
568
665
|
const singletonServices = getSingletonServices();
|
|
569
666
|
const { agent, packageName, resolvedName } = resolveAgent(agentName);
|
|
667
|
+
await assertAgentAuthorized(agent, params, packageName);
|
|
570
668
|
let agentRunner = singletonServices.aiAgentRunner;
|
|
571
669
|
if (!agentRunner) {
|
|
572
670
|
throw new AIProviderNotConfiguredError();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { saveMessages, resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
|
|
2
|
-
import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, } from './ai-agent-prepare.js';
|
|
2
|
+
import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertAgentAuthorized, } from './ai-agent-prepare.js';
|
|
3
3
|
import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js';
|
|
4
4
|
import { pikkuState, getSingletonServices } from '../../pikku-state.js';
|
|
5
5
|
import { resolveModelConfig } from './ai-agent-model-config.js';
|
|
@@ -293,6 +293,10 @@ export async function resumeAIAgentSync(runId, approvals, params, expectedAgentN
|
|
|
293
293
|
throw new Error(`Run ${runId} is not suspended (status: ${run.status})`);
|
|
294
294
|
}
|
|
295
295
|
const { agent, packageName, resolvedName } = resolveAgent(run.agentName);
|
|
296
|
+
// Resuming re-runs the agent, so it re-runs the agent's gate. Run ownership
|
|
297
|
+
// alone is not enough: a grant revoked while the run was suspended must stop
|
|
298
|
+
// the caller from approving its pending tool calls.
|
|
299
|
+
await assertAgentAuthorized(agent, params, packageName);
|
|
296
300
|
const { storage } = resolveMemoryServices(agent, singletonServices);
|
|
297
301
|
const memoryConfig = agent.memory;
|
|
298
302
|
const agentRunner = singletonServices.aiAgentRunner;
|
|
@@ -3,9 +3,9 @@ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
|
|
|
3
3
|
import { randomUUID } from './ai-agent-utils.js';
|
|
4
4
|
import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
|
|
5
5
|
import { resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
|
|
6
|
-
import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, ToolApprovalRequired, ToolCredentialRequired, APPROVAL_REQUIRED, } from './ai-agent-prepare.js';
|
|
6
|
+
import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertAgentAuthorized, ToolApprovalRequired, ToolCredentialRequired, APPROVAL_REQUIRED, } from './ai-agent-prepare.js';
|
|
7
7
|
import { resolveModelConfig } from './ai-agent-model-config.js';
|
|
8
|
-
function createPersistingChannel(parent, storage, threadId) {
|
|
8
|
+
function createPersistingChannel(parent, storage, threadId, logger) {
|
|
9
9
|
let fullText = '';
|
|
10
10
|
let stepText = '';
|
|
11
11
|
let stepGenerativeUI = null;
|
|
@@ -51,6 +51,22 @@ function createPersistingChannel(parent, storage, threadId) {
|
|
|
51
51
|
await storage.saveMessages(threadId, messages);
|
|
52
52
|
}
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* `send` is synchronous and so cannot await the flush. A rejection would have
|
|
56
|
+
* nothing to propagate to and would take the process down as an unhandled
|
|
57
|
+
* rejection — a model reusing a toolCallId, which is a primary key in AI
|
|
58
|
+
* storage, is enough to trigger it. Persistence from inside `send` is
|
|
59
|
+
* therefore best-effort: the run carries on, and the awaited `flush()` on the
|
|
60
|
+
* suspend paths still surfaces failures to its caller.
|
|
61
|
+
*/
|
|
62
|
+
const flushDetached = () => {
|
|
63
|
+
void flushStep().catch((error) => {
|
|
64
|
+
logger?.error('Failed to persist agent messages', {
|
|
65
|
+
threadId,
|
|
66
|
+
error,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
};
|
|
54
70
|
const channel = {
|
|
55
71
|
channelId: parent.channelId,
|
|
56
72
|
openingData: parent.openingData,
|
|
@@ -97,10 +113,10 @@ function createPersistingChannel(parent, storage, threadId) {
|
|
|
97
113
|
totalUsage.outputTokens += event.tokens.output;
|
|
98
114
|
if (event.model)
|
|
99
115
|
totalUsage.model = event.model;
|
|
100
|
-
|
|
116
|
+
flushDetached();
|
|
101
117
|
break;
|
|
102
118
|
case 'done':
|
|
103
|
-
|
|
119
|
+
flushDetached();
|
|
104
120
|
break;
|
|
105
121
|
}
|
|
106
122
|
}
|
|
@@ -469,7 +485,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
|
|
|
469
485
|
...streamMiddleware,
|
|
470
486
|
],
|
|
471
487
|
});
|
|
472
|
-
const persistingChannel = createPersistingChannel(channel, storage, threadId);
|
|
488
|
+
const persistingChannel = createPersistingChannel(channel, storage, threadId, singletonServices.logger);
|
|
473
489
|
const wrappedChannel = allChannelMiddleware.length > 0
|
|
474
490
|
? wrapChannelWithMiddleware({ channel: persistingChannel }, singletonServices, allChannelMiddleware).channel
|
|
475
491
|
: persistingChannel;
|
|
@@ -578,8 +594,13 @@ export async function resumeAIAgent(input, channel, params, options) {
|
|
|
578
594
|
if (!pending) {
|
|
579
595
|
throw new Error(`No pending approval for toolCallId ${input.toolCallId} on run ${input.runId}`);
|
|
580
596
|
}
|
|
581
|
-
await aiRunState.resolveApproval(input.toolCallId, input.approved ? 'approved' : 'denied');
|
|
582
597
|
const { agent, packageName, resolvedName } = resolveAgent(run.agentName);
|
|
598
|
+
// Gate before resolving the approval: recording it is a persisted side
|
|
599
|
+
// effect, so an unauthorized caller must not reach it. Run ownership alone is
|
|
600
|
+
// not enough — a grant revoked while the run was suspended must stop the
|
|
601
|
+
// caller from approving its pending tool calls.
|
|
602
|
+
await assertAgentAuthorized(agent, params, packageName);
|
|
603
|
+
await aiRunState.resolveApproval(input.toolCallId, input.approved ? 'approved' : 'denied');
|
|
583
604
|
const { storage } = resolveMemoryServices(agent, singletonServices);
|
|
584
605
|
const memoryConfig = agent.memory;
|
|
585
606
|
const agentRunner = singletonServices.aiAgentRunner;
|
|
@@ -807,7 +828,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
|
|
|
807
828
|
...streamMiddleware,
|
|
808
829
|
],
|
|
809
830
|
});
|
|
810
|
-
const persistingChannel = createPersistingChannel(channel, storage, run.threadId);
|
|
831
|
+
const persistingChannel = createPersistingChannel(channel, storage, run.threadId, singletonServices.logger);
|
|
811
832
|
const wrappedChannel = allChannelMiddleware.length > 0
|
|
812
833
|
? wrapChannelWithMiddleware({ channel: persistingChannel }, singletonServices, allChannelMiddleware).channel
|
|
813
834
|
: persistingChannel;
|
|
@@ -211,7 +211,7 @@ export type AIAgentMemoryConfig = {
|
|
|
211
211
|
lastMessages?: number;
|
|
212
212
|
workingMemory?: unknown;
|
|
213
213
|
};
|
|
214
|
-
export type CoreAIAgent<PikkuPermission = CorePikkuPermission<any, any>, PikkuMiddleware = CorePikkuMiddleware<any
|
|
214
|
+
export type CoreAIAgent<PikkuPermission = CorePikkuPermission<any, any>, PikkuMiddleware = CorePikkuMiddleware<any>, Scope extends string = string> = {
|
|
215
215
|
name: string;
|
|
216
216
|
description: string;
|
|
217
217
|
summary?: string;
|
|
@@ -244,6 +244,23 @@ export type CoreAIAgent<PikkuPermission = CorePikkuPermission<any, any>, PikkuMi
|
|
|
244
244
|
middleware?: PikkuMiddleware[];
|
|
245
245
|
channelMiddleware?: CorePikkuChannelMiddleware<any, any>[];
|
|
246
246
|
aiMiddleware?: PikkuAIMiddlewareHooks<any, any>[];
|
|
247
|
+
/**
|
|
248
|
+
* Whether a session is required to run this agent. Defaults to `false`, since
|
|
249
|
+
* agents are commonly invoked from an already-authenticated `pikkuFunc` or
|
|
250
|
+
* from genuinely sessionless contexts (crons, queue workers). Set `true` to
|
|
251
|
+
* require a session at the agent itself. `scopes` and `permissions` are
|
|
252
|
+
* enforced either way.
|
|
253
|
+
*/
|
|
254
|
+
auth?: boolean;
|
|
255
|
+
/**
|
|
256
|
+
* Scopes the session must hold to run this agent. All of them are required
|
|
257
|
+
* (AND), and they are checked before `permissions` — unlike permissions,
|
|
258
|
+
* which OR together, a scope can only narrow access.
|
|
259
|
+
*
|
|
260
|
+
* Narrowed to the generated `ScopeId` union in a project's own
|
|
261
|
+
* `pikku-types.gen.ts`, so an undeclared scope is a compile error.
|
|
262
|
+
*/
|
|
263
|
+
scopes?: Scope[];
|
|
247
264
|
permissions?: CorePermissionGroup<PikkuPermission>;
|
|
248
265
|
};
|
|
249
266
|
export type AIStreamEvent = {
|
|
@@ -413,6 +430,17 @@ export interface AgentRunService {
|
|
|
413
430
|
listThreads(options?: {
|
|
414
431
|
agentName?: string;
|
|
415
432
|
resourceId?: string;
|
|
433
|
+
/**
|
|
434
|
+
* Restrict results to threads owned by one of these session principals. A
|
|
435
|
+
* thread matches when its `resourceId` is the principal itself or one of its
|
|
436
|
+
* `principal:` sub-partitions, mirroring the composition
|
|
437
|
+
* `resolveOwnerResourceId` writes.
|
|
438
|
+
*
|
|
439
|
+
* Unlike `resourceId`, which is an optional exact-match filter, this is an
|
|
440
|
+
* authorization constraint: an empty array matches nothing. Callers exposing
|
|
441
|
+
* threads over the wire must derive it from the session, never from input.
|
|
442
|
+
*/
|
|
443
|
+
owners?: string[];
|
|
416
444
|
limit?: number;
|
|
417
445
|
offset?: number;
|
|
418
446
|
}): Promise<AIThread[]>;
|
|
@@ -4,6 +4,6 @@ export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js';
|
|
|
4
4
|
export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js';
|
|
5
5
|
export { voiceInput } from './voice-input.js';
|
|
6
6
|
export { voiceOutput } from './voice-output.js';
|
|
7
|
-
export { type RunAIAgentParams, type StreamAIAgentOptions, ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
|
|
7
|
+
export { type RunAIAgentParams, type StreamAIAgentOptions, ToolApprovalRequired, ToolCredentialRequired, canAccessThread, isOwnedByPrincipal, sessionPrincipals, threadOwnerConstraint, } from './ai-agent-prepare.js';
|
|
8
8
|
export { addAIAgent, approveAIAgent, getAIAgents, getAIAgentsMeta, } from './ai-agent-registry.js';
|
|
9
9
|
export type { AIAgentInput, AIAgentInputAttachment, AIAgentMeta, AIAgentMemoryConfig, AIAgentStep, AIContentPart, AgentRunRow, AgentRunService, AgentRunState, AIMessage, AIStreamChannel, AIStreamEvent, AIThread, CoreAIAgent, PendingApproval, PikkuAIMiddlewareHooks, } from './ai-agent.types.js';
|
|
@@ -4,5 +4,5 @@ export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js';
|
|
|
4
4
|
export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js';
|
|
5
5
|
export { voiceInput } from './voice-input.js';
|
|
6
6
|
export { voiceOutput } from './voice-output.js';
|
|
7
|
-
export { ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
|
|
7
|
+
export { ToolApprovalRequired, ToolCredentialRequired, canAccessThread, isOwnedByPrincipal, sessionPrincipals, threadOwnerConstraint, } from './ai-agent-prepare.js';
|
|
8
8
|
export { addAIAgent, approveAIAgent, getAIAgents, getAIAgentsMeta, } from './ai-agent-registry.js';
|
|
@@ -23,8 +23,8 @@ async function fetchAsUint8Array(url, allowedAudioHosts) {
|
|
|
23
23
|
}
|
|
24
24
|
export const voiceInput = (config) => pikkuAIMiddleware({
|
|
25
25
|
modifyInput: async (services, { messages, instructions }) => {
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
26
|
+
const aiAgentRunner = services.aiAgentRunner;
|
|
27
|
+
if (!aiAgentRunner?.transcribe)
|
|
28
28
|
return { messages, instructions };
|
|
29
29
|
const last = messages[messages.length - 1];
|
|
30
30
|
if (!last || last.role !== 'user' || typeof last.content === 'string') {
|
|
@@ -51,7 +51,7 @@ export const voiceInput = (config) => pikkuAIMiddleware({
|
|
|
51
51
|
const audioData = p.data
|
|
52
52
|
? base64ToUint8Array(p.data)
|
|
53
53
|
: await fetchAsUint8Array(p.url, config.allowedAudioHosts);
|
|
54
|
-
const result = await
|
|
54
|
+
const result = await aiAgentRunner.transcribe({
|
|
55
55
|
model: config.model,
|
|
56
56
|
audio: audioData,
|
|
57
57
|
...(config.language
|
|
@@ -319,6 +319,9 @@ export async function executeCLI({ programName, args, createConfig, createSingle
|
|
|
319
319
|
console.log(helpText);
|
|
320
320
|
return;
|
|
321
321
|
}
|
|
322
|
+
// Non-fatal diagnostics (unknown options are still accepted) go to stderr
|
|
323
|
+
// so they never pollute a command's machine-readable stdout.
|
|
324
|
+
parsed.warnings.forEach((warning) => console.error(`Warning: ${warning}`));
|
|
322
325
|
if (parsed.errors.length > 0) {
|
|
323
326
|
// Check if any error is about an unknown command
|
|
324
327
|
const hasUnknownCommand = parsed.errors.some((error) => error.startsWith('Unknown command:') ||
|
|
@@ -8,6 +8,8 @@ export interface ParsedCommand {
|
|
|
8
8
|
positionals: Record<string, any>;
|
|
9
9
|
options: Record<string, any>;
|
|
10
10
|
errors: string[];
|
|
11
|
+
/** Non-fatal diagnostics (e.g. unknown options that were accepted+ignored) */
|
|
12
|
+
warnings: string[];
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* Parses raw CLI arguments into structured data for a specific program
|
|
@@ -8,6 +8,54 @@ function toCamelCase(str) {
|
|
|
8
8
|
function toKebabCase(str) {
|
|
9
9
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
10
10
|
}
|
|
11
|
+
/** Options the runner handles itself — never reported as unknown. */
|
|
12
|
+
const RESERVED_OPTIONS = new Set(['help']);
|
|
13
|
+
/** Levenshtein distance, capped-free and dependency-free. Used only to suggest
|
|
14
|
+
* a near-miss option name, so the naive O(n*m) implementation is fine. */
|
|
15
|
+
function levenshtein(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return 0;
|
|
18
|
+
if (a.length === 0)
|
|
19
|
+
return b.length;
|
|
20
|
+
if (b.length === 0)
|
|
21
|
+
return a.length;
|
|
22
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
23
|
+
for (let i = 1; i <= a.length; i++) {
|
|
24
|
+
const row = [i];
|
|
25
|
+
for (let j = 1; j <= b.length; j++) {
|
|
26
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
27
|
+
row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
28
|
+
}
|
|
29
|
+
prev = row;
|
|
30
|
+
}
|
|
31
|
+
return prev[b.length];
|
|
32
|
+
}
|
|
33
|
+
/** Finds the closest declared option (distance <= 2) to what the user typed.
|
|
34
|
+
* Compares against the kebab-case rendering, since that is what is typed. */
|
|
35
|
+
function suggestOption(typed, availableOptions) {
|
|
36
|
+
let best = null;
|
|
37
|
+
let bestDistance = 3;
|
|
38
|
+
for (const name of Object.keys(availableOptions)) {
|
|
39
|
+
const kebab = toKebabCase(name);
|
|
40
|
+
const distance = Math.min(levenshtein(typed, kebab), levenshtein(typed, name));
|
|
41
|
+
if (distance < bestDistance) {
|
|
42
|
+
bestDistance = distance;
|
|
43
|
+
best = kebab;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return best;
|
|
47
|
+
}
|
|
48
|
+
/** Records a warning that an unknown long option was accepted but ignored.
|
|
49
|
+
* Unknown options stay non-fatal for forward compatibility (a newer command
|
|
50
|
+
* version may understand them) — they are just no longer silent. */
|
|
51
|
+
function warnUnknownOption(typed, availableOptions, result) {
|
|
52
|
+
if (RESERVED_OPTIONS.has(toCamelCase(typed))) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const suggestion = suggestOption(typed, availableOptions);
|
|
56
|
+
result.warnings.push(`Unknown option: --${typed} (ignored)` +
|
|
57
|
+
(suggestion ? ` Did you mean --${suggestion}?` : ''));
|
|
58
|
+
}
|
|
11
59
|
/**
|
|
12
60
|
* Parses raw CLI arguments into structured data for a specific program
|
|
13
61
|
*/
|
|
@@ -18,6 +66,7 @@ export function parseCLIArguments(args, programName, allMeta) {
|
|
|
18
66
|
positionals: {},
|
|
19
67
|
options: {},
|
|
20
68
|
errors: [],
|
|
69
|
+
warnings: [],
|
|
21
70
|
};
|
|
22
71
|
const meta = allMeta.programs[programName];
|
|
23
72
|
if (!meta) {
|
|
@@ -106,7 +155,11 @@ export function parseCLIArguments(args, programName, allMeta) {
|
|
|
106
155
|
// --option=value format
|
|
107
156
|
const key = toCamelCase(arg.slice(2, equalIndex));
|
|
108
157
|
const optionDef = availableOptions[key];
|
|
109
|
-
// Unknown options are allowed for forward compatibility
|
|
158
|
+
// Unknown options are allowed for forward compatibility, but warned
|
|
159
|
+
// about so they are not silently dropped by the input schema.
|
|
160
|
+
if (!optionDef) {
|
|
161
|
+
warnUnknownOption(arg.slice(2, equalIndex), availableOptions, result);
|
|
162
|
+
}
|
|
110
163
|
const value = arg.slice(equalIndex + 1);
|
|
111
164
|
optionArgs[key] = parseOptionValue(value, optionDef);
|
|
112
165
|
}
|
|
@@ -114,7 +167,11 @@ export function parseCLIArguments(args, programName, allMeta) {
|
|
|
114
167
|
// --option value format
|
|
115
168
|
const key = toCamelCase(arg.slice(2));
|
|
116
169
|
const optionDef = availableOptions[key];
|
|
117
|
-
// Unknown options are allowed for forward compatibility
|
|
170
|
+
// Unknown options are allowed for forward compatibility, but warned
|
|
171
|
+
// about so they are not silently dropped by the input schema.
|
|
172
|
+
if (!optionDef) {
|
|
173
|
+
warnUnknownOption(arg.slice(2), availableOptions, result);
|
|
174
|
+
}
|
|
118
175
|
if (optionDef && optionDef.array) {
|
|
119
176
|
// Array option - collect all following non-flag values
|
|
120
177
|
currentIndex++;
|
|
@@ -5,6 +5,13 @@ export type CoreCredential<T = unknown> = {
|
|
|
5
5
|
description?: string;
|
|
6
6
|
type: 'singleton' | 'wire';
|
|
7
7
|
schema: T;
|
|
8
|
+
/**
|
|
9
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
10
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
11
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
12
|
+
* an opaque identifier.
|
|
13
|
+
*/
|
|
14
|
+
docsUrl?: string;
|
|
8
15
|
oauth2?: OAuth2CredentialConfig & {
|
|
9
16
|
appCredentialSecretId: string;
|
|
10
17
|
};
|
|
@@ -15,6 +22,13 @@ export type CredentialDefinitionMeta = {
|
|
|
15
22
|
description?: string;
|
|
16
23
|
type: 'singleton' | 'wire';
|
|
17
24
|
schema?: Record<string, unknown> | string;
|
|
25
|
+
/**
|
|
26
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
27
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
28
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
29
|
+
* an opaque identifier.
|
|
30
|
+
*/
|
|
31
|
+
docsUrl?: string;
|
|
18
32
|
oauth2?: OAuth2CredentialConfig & {
|
|
19
33
|
appCredentialSecretId: string;
|
|
20
34
|
};
|