@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/permissions.d.ts +12 -4
  5. package/dist/permissions.js +11 -32
  6. package/dist/scopes.d.ts +14 -0
  7. package/dist/scopes.js +39 -8
  8. package/dist/testing/service-tests.js +37 -0
  9. package/dist/types/core.types.d.ts +22 -0
  10. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  11. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  12. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  13. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  14. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  15. package/dist/wirings/ai-agent/index.d.ts +1 -1
  16. package/dist/wirings/ai-agent/index.js +1 -1
  17. package/dist/wirings/ai-agent/voice-input.js +3 -3
  18. package/dist/wirings/cli/cli-runner.js +3 -0
  19. package/dist/wirings/cli/command-parser.d.ts +2 -0
  20. package/dist/wirings/cli/command-parser.js +59 -2
  21. package/dist/wirings/credential/credential.types.d.ts +14 -0
  22. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  23. package/dist/wirings/gateway/gateway-runner.js +100 -50
  24. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  25. package/dist/wirings/http/http.types.d.ts +3 -3
  26. package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
  27. package/dist/wirings/scope/validate-scope-definitions.js +16 -1
  28. package/dist/wirings/secret/secret.types.d.ts +14 -0
  29. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  30. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  31. package/dist/wirings/variable/variable.types.d.ts +14 -0
  32. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  33. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  34. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  35. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  36. package/package.json +2 -1
  37. package/src/index.ts +2 -1
  38. package/src/permissions.test.ts +14 -8
  39. package/src/permissions.ts +14 -36
  40. package/src/scopes.test.ts +37 -1
  41. package/src/scopes.ts +48 -9
  42. package/src/testing/service-tests.ts +49 -0
  43. package/src/types/core.types.ts +23 -0
  44. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  45. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  46. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  47. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  48. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  49. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  50. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  51. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  52. package/src/wirings/ai-agent/index.ts +4 -0
  53. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  54. package/src/wirings/ai-agent/voice-input.ts +8 -10
  55. package/src/wirings/cli/cli-runner.ts +4 -0
  56. package/src/wirings/cli/command-parser.test.ts +130 -0
  57. package/src/wirings/cli/command-parser.ts +80 -2
  58. package/src/wirings/credential/credential.types.ts +14 -0
  59. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  60. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  61. package/src/wirings/gateway/gateway-runner.ts +114 -68
  62. package/src/wirings/gateway/gateway.types.ts +7 -9
  63. package/src/wirings/http/http.types.ts +6 -4
  64. package/src/wirings/scope/scope.test.ts +25 -0
  65. package/src/wirings/scope/validate-scope-definitions.ts +16 -1
  66. package/src/wirings/secret/secret.types.ts +14 -0
  67. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  68. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  69. package/src/wirings/variable/variable.types.ts +14 -0
  70. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  71. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  72. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  73. package/src/wirings/workflow/workflow.types.ts +0 -2
  74. 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 allowed = await checkAuthPermissions(fnMeta.permissions, session, singletonServices, resolvedPkg);
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 allowed = await checkAuthPermissions(subMeta.permissions, session, singletonServices);
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
- flushStep();
116
+ flushDetached();
101
117
  break;
102
118
  case 'done':
103
- flushStep();
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 transcribeAudio = services.aiAgentRunner?.transcribe;
27
- if (!transcribeAudio)
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 transcribeAudio({
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
  };
@@ -31,6 +31,7 @@ export function validateAndBuildCredentialDefinitionsMeta(definitions, schemaLoo
31
31
  type: def.type,
32
32
  schema: def.schema,
33
33
  oauth2: def.oauth2,
34
+ docsUrl: def.docsUrl,
34
35
  sourceFile: def.sourceFile,
35
36
  };
36
37
  }