@pikku/core 0.12.23 → 0.12.24

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 (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/function/function-runner.js +62 -15
  3. package/dist/function/functions.types.d.ts +8 -5
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.js +1 -0
  6. package/dist/middleware-runner.d.ts +2 -2
  7. package/dist/permissions.d.ts +1 -1
  8. package/dist/services/ai-agent-runner-service.d.ts +3 -0
  9. package/dist/services/audit-service.d.ts +50 -0
  10. package/dist/services/audit-service.js +106 -0
  11. package/dist/services/content-service.d.ts +1 -0
  12. package/dist/services/email-service.d.ts +36 -0
  13. package/dist/services/email-service.js +1 -0
  14. package/dist/services/index.d.ts +5 -1
  15. package/dist/services/index.js +2 -0
  16. package/dist/services/local-content.js +1 -0
  17. package/dist/services/local-email-service.d.ts +4 -0
  18. package/dist/services/local-email-service.js +26 -0
  19. package/dist/services/local-secrets.d.ts +1 -0
  20. package/dist/services/local-secrets.js +11 -2
  21. package/dist/services/meta-service.d.ts +36 -0
  22. package/dist/services/meta-service.js +59 -0
  23. package/dist/types/core.types.d.ts +15 -1
  24. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +2 -0
  25. package/dist/wirings/ai-agent/ai-agent-prepare.js +8 -2
  26. package/dist/wirings/ai-agent/ai-agent-runner.js +1 -1
  27. package/dist/wirings/ai-agent/ai-agent-stream.js +1 -1
  28. package/dist/wirings/ai-agent/ai-agent-utils.d.ts +1 -0
  29. package/dist/wirings/ai-agent/ai-agent-utils.js +1 -0
  30. package/dist/wirings/rpc/rpc-runner.js +9 -2
  31. package/package.json +1 -1
  32. package/src/function/function-runner.test.ts +255 -0
  33. package/src/function/function-runner.ts +66 -20
  34. package/src/function/functions.types.ts +26 -11
  35. package/src/index.ts +35 -0
  36. package/src/middleware-runner.ts +10 -10
  37. package/src/permissions.ts +4 -4
  38. package/src/services/ai-agent-runner-service.ts +3 -0
  39. package/src/services/audit-service.test.ts +44 -0
  40. package/src/services/audit-service.ts +198 -0
  41. package/src/services/content-service.ts +1 -0
  42. package/src/services/email-service.ts +46 -0
  43. package/src/services/index.ts +33 -0
  44. package/src/services/local-content.ts +1 -0
  45. package/src/services/local-email-service.test.ts +108 -0
  46. package/src/services/local-email-service.ts +30 -0
  47. package/src/services/local-secrets.ts +11 -2
  48. package/src/services/meta-service.ts +115 -0
  49. package/src/types/core.types.ts +20 -2
  50. package/src/wirings/ai-agent/ai-agent-prepare.ts +11 -2
  51. package/src/wirings/ai-agent/ai-agent-runner.test.ts +105 -0
  52. package/src/wirings/ai-agent/ai-agent-runner.ts +1 -1
  53. package/src/wirings/ai-agent/ai-agent-stream.ts +1 -1
  54. package/src/wirings/ai-agent/ai-agent-utils.ts +1 -0
  55. package/src/wirings/rpc/rpc-runner.ts +9 -2
  56. package/tsconfig.tsbuildinfo +1 -1
@@ -17,6 +17,7 @@ export {
17
17
  } from './credential-wire-service.js'
18
18
  export { TypedVariablesService } from './typed-variables-service.js'
19
19
  export { LocalSecretService } from './local-secrets.js'
20
+ export { LocalEmailService } from './local-email-service.js'
20
21
  export { LocalCredentialService } from './local-credential-service.js'
21
22
  export { LocalVariablesService } from './local-variables.js'
22
23
  export { ConsoleLogger, JsonConsoleLogger } from './logger-console.js'
@@ -36,6 +37,15 @@ export type {
36
37
  CopyFileArgs,
37
38
  } from './content-service.js'
38
39
  export type { JWTService } from './jwt-service.js'
40
+ export type {
41
+ EmailService,
42
+ EmailTemplateReference,
43
+ SendEmailInput,
44
+ SendEmailResult,
45
+ SendHTMLEmailInput,
46
+ SendTemplateEmailInput,
47
+ SendTextEmailInput,
48
+ } from './email-service.js'
39
49
  export type { Logger } from './logger.js'
40
50
  export type { SecretService } from './secret-service.js'
41
51
  export type { VariablesService } from './variables-service.js'
@@ -78,6 +88,25 @@ export type {
78
88
  export type { VariableStatus, VariableMeta } from './typed-variables-service.js'
79
89
  export type { MetaService } from './meta-service.js'
80
90
  export type { SessionStore } from './session-store.js'
91
+ export {
92
+ NoopAuditService,
93
+ createInvocationAudit,
94
+ resolveAuditActorFromWire,
95
+ resolveAuditConfig,
96
+ } from './audit-service.js'
97
+ export type {
98
+ AuditActor,
99
+ AuditConfig,
100
+ AuditDurability,
101
+ AuditEvent,
102
+ AuditEventBatch,
103
+ AuditLog,
104
+ AuditLogWriteInput,
105
+ AuditOutcome,
106
+ AuditService,
107
+ AuditSource,
108
+ ResolvedAuditConfig,
109
+ } from './audit-service.js'
81
110
  export { InMemorySessionStore } from './in-memory-session-store.js'
82
111
  export type {
83
112
  MCPMeta,
@@ -96,4 +125,8 @@ export type {
96
125
  PermissionMeta,
97
126
  AgentsMeta,
98
127
  AgentMeta,
128
+ EmailsMeta,
129
+ EmailTemplateMeta,
130
+ EmailTemplateLocaleMeta,
131
+ EmailTemplateAssets,
99
132
  } from './meta-service.js'
@@ -106,6 +106,7 @@ export class LocalContent implements ContentService {
106
106
  }
107
107
 
108
108
  public async getUploadURL(args: GetUploadURLArgs): Promise<UploadURLResult> {
109
+ void args.visibility
109
110
  const fullKey = this.joinKey(args.bucket, args.fileKey)
110
111
  this.logger.debug(`Going to upload with key: ${fullKey}`)
111
112
  return {
@@ -0,0 +1,108 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ import { LocalEmailService } from './local-email-service.js'
5
+
6
+ test('LocalEmailService logs sent email metadata as JSON', async () => {
7
+ const service = new LocalEmailService()
8
+ const writes: string[] = []
9
+ const originalInfo = console.info
10
+ console.info = (value?: unknown) => {
11
+ writes.push(String(value))
12
+ }
13
+
14
+ try {
15
+ await service.send({
16
+ to: 'user@example.com',
17
+ from: 'hello@example.com',
18
+ subject: 'Welcome',
19
+ template: {
20
+ name: 'welcome',
21
+ locale: 'en',
22
+ data: { userName: 'Yasser' },
23
+ },
24
+ })
25
+ } finally {
26
+ console.info = originalInfo
27
+ }
28
+
29
+ assert.equal(writes.length, 1)
30
+ const payload = JSON.parse(writes[0])
31
+ assert.equal(payload.type, 'email')
32
+ assert.equal(payload.message, 'this email was sent')
33
+ assert.equal(payload.to, 'user@example.com')
34
+ assert.equal(payload.from, 'hello@example.com')
35
+ assert.equal(payload.subject, 'Welcome')
36
+ assert.deepEqual(payload.template, {
37
+ name: 'welcome',
38
+ locale: 'en',
39
+ data: { userName: 'Yasser' },
40
+ })
41
+ })
42
+
43
+ test('LocalEmailService logs htmlLength and textLength for HTML email', async () => {
44
+ const service = new LocalEmailService()
45
+ const writes: string[] = []
46
+ const originalInfo = console.info
47
+ console.info = (value?: unknown) => {
48
+ writes.push(String(value))
49
+ }
50
+
51
+ try {
52
+ await service.send({
53
+ to: 'user@example.com',
54
+ html: '<p>Hello</p>',
55
+ text: 'Hello',
56
+ })
57
+ } finally {
58
+ console.info = originalInfo
59
+ }
60
+
61
+ const payload = JSON.parse(writes[0])
62
+ assert.equal(payload.htmlLength, '<p>Hello</p>'.length)
63
+ assert.equal(payload.textLength, 'Hello'.length)
64
+ })
65
+
66
+ test('LocalEmailService logs textLength only for plain text email', async () => {
67
+ const service = new LocalEmailService()
68
+ const writes: string[] = []
69
+ const originalInfo = console.info
70
+ console.info = (value?: unknown) => {
71
+ writes.push(String(value))
72
+ }
73
+
74
+ try {
75
+ await service.send({
76
+ to: 'user@example.com',
77
+ text: 'Plain text body',
78
+ })
79
+ } finally {
80
+ console.info = originalInfo
81
+ }
82
+
83
+ const payload = JSON.parse(writes[0])
84
+ assert.equal(payload.textLength, 'Plain text body'.length)
85
+ assert.equal(payload.htmlLength, undefined)
86
+ })
87
+
88
+ test('LocalEmailService logs null textLength for HTML email without plain text', async () => {
89
+ const service = new LocalEmailService()
90
+ const writes: string[] = []
91
+ const originalInfo = console.info
92
+ console.info = (value?: unknown) => {
93
+ writes.push(String(value))
94
+ }
95
+
96
+ try {
97
+ await service.send({
98
+ to: 'user@example.com',
99
+ html: '<p>Hello</p>',
100
+ })
101
+ } finally {
102
+ console.info = originalInfo
103
+ }
104
+
105
+ const payload = JSON.parse(writes[0])
106
+ assert.equal(payload.htmlLength, '<p>Hello</p>'.length)
107
+ assert.equal(payload.textLength, null)
108
+ })
@@ -0,0 +1,30 @@
1
+ import type { EmailService, SendEmailInput, SendEmailResult } from './email-service.js'
2
+
3
+ export class LocalEmailService implements EmailService {
4
+ async send(input: SendEmailInput): Promise<SendEmailResult> {
5
+ const payload: Record<string, unknown> = {
6
+ type: 'email',
7
+ message: 'this email was sent',
8
+ to: input.to,
9
+ from: input.from ?? null,
10
+ cc: input.cc ?? null,
11
+ bcc: input.bcc ?? null,
12
+ replyTo: input.replyTo ?? null,
13
+ subject: input.subject ?? null,
14
+ }
15
+
16
+ if ('template' in input && input.template) {
17
+ payload.template = input.template
18
+ }
19
+ if ('html' in input && typeof input.html === 'string') {
20
+ payload.htmlLength = input.html.length
21
+ payload.textLength = input.text?.length ?? null
22
+ }
23
+ if ('text' in input && typeof input.text === 'string') {
24
+ payload.textLength = input.text.length
25
+ }
26
+
27
+ console.info(JSON.stringify(payload))
28
+ return {}
29
+ }
30
+ }
@@ -9,6 +9,15 @@ import type { VariablesService } from './variables-service.js'
9
9
  export class LocalSecretService implements SecretService {
10
10
  private localSecrets: Map<string, string> = new Map()
11
11
 
12
+ // TODO: Drop this fallback once secrets and variables expose aligned typed/raw access paths again.
13
+ private parseSecret<R>(raw: string): R {
14
+ try {
15
+ return JSON.parse(raw) as R
16
+ } catch {
17
+ return raw as unknown as R
18
+ }
19
+ }
20
+
12
21
  /**
13
22
  * Creates an instance of LocalSecretService.
14
23
  */
@@ -27,13 +36,13 @@ export class LocalSecretService implements SecretService {
27
36
  // Check local storage first
28
37
  const localValue = this.localSecrets.get(key)
29
38
  if (localValue) {
30
- return JSON.parse(localValue)
39
+ return this.parseSecret(localValue)
31
40
  }
32
41
 
33
42
  // Fall back to environment variables
34
43
  const value = await this.variables.get(key)
35
44
  if (value) {
36
- return JSON.parse(value)
45
+ return this.parseSecret(value)
37
46
  }
38
47
  throw new Error('Requested secret not found')
39
48
  }
@@ -109,6 +109,38 @@ export interface PermissionsGroupsMeta {
109
109
  tagGroups: Record<string, GroupMeta>
110
110
  }
111
111
 
112
+ export interface EmailTemplateLocaleMeta {
113
+ contentHash: string
114
+ htmlHash: string
115
+ subjectHash: string
116
+ textHash: string
117
+ }
118
+
119
+ export interface EmailTemplateMeta {
120
+ variables: string[]
121
+ hasHtml: boolean
122
+ hasSubject: boolean
123
+ hasText: boolean
124
+ locales: Record<string, EmailTemplateLocaleMeta>
125
+ }
126
+
127
+ export interface EmailsMeta {
128
+ src: string
129
+ themeHash: string
130
+ templates: Record<string, EmailTemplateMeta>
131
+ }
132
+
133
+ export interface EmailTemplateAssets {
134
+ theme: Record<string, unknown>
135
+ strings: Record<string, unknown>
136
+ layout: string
137
+ partials: Record<string, string>
138
+ html: string
139
+ subject: string
140
+ text: string
141
+ missing: string[]
142
+ }
143
+
112
144
  /**
113
145
  * Abstraction over .pikku metadata file access.
114
146
  * All paths are relative to the .pikku root directory.
@@ -123,6 +155,8 @@ export interface MetaService {
123
155
  readFile(relativePath: string): Promise<string | null>
124
156
  /** List files in a directory by relative path. Returns empty array if not found. */
125
157
  readDir(relativePath: string): Promise<string[]>
158
+ /** Read a project source file relative to the project root (one level above .pikku). */
159
+ readProjectFile(relativePath: string): Promise<string | null>
126
160
 
127
161
  // -- Typed metadata accessors --
128
162
 
@@ -143,6 +177,11 @@ export interface MetaService {
143
177
  getSecretsMeta(): Promise<SecretDefinitionsMeta>
144
178
  getCredentialsMeta(): Promise<CredentialDefinitionsMeta>
145
179
  getVariablesMeta(): Promise<VariableDefinitionsMeta>
180
+ getEmailMeta(): Promise<EmailsMeta>
181
+ getEmailTemplateAssets(
182
+ templateName: string,
183
+ locale: string
184
+ ): Promise<EmailTemplateAssets>
146
185
  getServicesMeta(): Promise<ServicesMetaRecord>
147
186
 
148
187
  getSchema(schemaName: string): Promise<JSONSchema7 | null>
@@ -173,6 +212,7 @@ export class LocalMetaService implements MetaService {
173
212
  private secretsMetaCache: SecretDefinitionsMeta | null = null
174
213
  private credentialsMetaCache: CredentialDefinitionsMeta | null = null
175
214
  private variablesMetaCache: VariableDefinitionsMeta | null = null
215
+ private emailMetaCache: EmailsMeta | null = null
176
216
  private middlewareGroupsMetaCache: MiddlewareGroupsMeta | null = null
177
217
  private permissionsGroupsMetaCache: PermissionsGroupsMeta | null = null
178
218
  private agentsMetaCache: AgentsMeta | null = null
@@ -198,6 +238,14 @@ export class LocalMetaService implements MetaService {
198
238
  }
199
239
  }
200
240
 
241
+ async readProjectFile(relativePath: string): Promise<string | null> {
242
+ try {
243
+ return await readFile(join(this.basePath, '..', relativePath), 'utf-8')
244
+ } catch {
245
+ return null
246
+ }
247
+ }
248
+
201
249
  clearCache(): void {
202
250
  this.httpMetaCache = null
203
251
  this.channelsMetaCache = null
@@ -214,6 +262,7 @@ export class LocalMetaService implements MetaService {
214
262
  this.secretsMetaCache = null
215
263
  this.credentialsMetaCache = null
216
264
  this.variablesMetaCache = null
265
+ this.emailMetaCache = null
217
266
  this.middlewareGroupsMetaCache = null
218
267
  this.permissionsGroupsMetaCache = null
219
268
  this.agentsMetaCache = null
@@ -459,6 +508,72 @@ export class LocalMetaService implements MetaService {
459
508
  return this.variablesMetaCache!
460
509
  }
461
510
 
511
+ async getEmailMeta(): Promise<EmailsMeta> {
512
+ if (this.emailMetaCache) return this.emailMetaCache
513
+
514
+ const content = await this.readFile('email/pikku-emails-meta.gen.json')
515
+ this.emailMetaCache = content
516
+ ? JSON.parse(content)
517
+ : {
518
+ src: '',
519
+ themeHash: '',
520
+ templates: {},
521
+ }
522
+ return this.emailMetaCache!
523
+ }
524
+
525
+ async getEmailTemplateAssets(
526
+ templateName: string,
527
+ locale: string
528
+ ): Promise<EmailTemplateAssets> {
529
+ const emailsMeta = await this.getEmailMeta()
530
+ if (!emailsMeta.src) {
531
+ throw new Error(
532
+ 'No generated email metadata found. Run `pikku emails generate`.'
533
+ )
534
+ }
535
+
536
+ const baseDir = emailsMeta.src
537
+ const [
538
+ themeRaw,
539
+ localeRaw,
540
+ layoutRaw,
541
+ footerRaw,
542
+ templateHtml,
543
+ templateSubject,
544
+ templateText,
545
+ ] = await Promise.all([
546
+ this.readProjectFile(`${baseDir}/theme.json`),
547
+ this.readProjectFile(`${baseDir}/locales/${locale}.json`),
548
+ this.readProjectFile(`${baseDir}/partials/layout.html`),
549
+ this.readProjectFile(`${baseDir}/partials/footer.html`),
550
+ this.readProjectFile(`${baseDir}/templates/${templateName}.html`),
551
+ this.readProjectFile(`${baseDir}/templates/${templateName}.subject.txt`),
552
+ this.readProjectFile(`${baseDir}/templates/${templateName}.text.txt`),
553
+ ])
554
+
555
+ return {
556
+ theme: themeRaw ? (JSON.parse(themeRaw) as Record<string, unknown>) : {},
557
+ strings: localeRaw
558
+ ? (JSON.parse(localeRaw) as Record<string, unknown>)
559
+ : {},
560
+ layout: layoutRaw ?? '',
561
+ partials: {
562
+ footer: footerRaw ?? '',
563
+ },
564
+ html: templateHtml ?? '',
565
+ subject: templateSubject ?? '',
566
+ text: templateText ?? '',
567
+ missing: [
568
+ ...(themeRaw ? [] : ['theme']),
569
+ ...(localeRaw ? [] : ['locale']),
570
+ ...(templateHtml ? [] : ['html']),
571
+ ...(templateSubject ? [] : ['subject']),
572
+ ...(templateText ? [] : ['text']),
573
+ ],
574
+ }
575
+ }
576
+
462
577
  async getServicesMeta(): Promise<ServicesMetaRecord> {
463
578
  if (this.servicesMetaCache) return this.servicesMetaCache
464
579
 
@@ -35,8 +35,13 @@ import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js'
35
35
  import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.js'
36
36
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js'
37
37
  import type { CredentialService } from '../services/credential-service.js'
38
+ import type { EmailService } from '../services/email-service.js'
38
39
  import type { MetaService } from '../services/meta-service.js'
39
40
  import type { SessionStore } from '../services/session-store.js'
41
+ import type {
42
+ AuditDurability,
43
+ AuditService,
44
+ } from '../services/audit-service.js'
40
45
 
41
46
  export type PikkuWiringTypes =
42
47
  | 'http'
@@ -196,7 +201,10 @@ export type CoreConfig<Config extends Record<string, unknown> = {}> = {
196
201
  /**
197
202
  * Represents a core user session, which can be extended for more specific session information.
198
203
  */
199
- export interface CoreUserSession {}
204
+ export interface CoreUserSession {
205
+ userId?: string
206
+ orgId?: string
207
+ }
200
208
 
201
209
  /**
202
210
  * Interface for core singleton services provided by Pikku.
@@ -239,8 +247,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
239
247
  workflowRunService?: WorkflowRunService
240
248
  /** Credential service for dynamic/managed credentials (OAuth tokens, per-user API keys) */
241
249
  credentialService?: CredentialService
250
+ /** Email service for outbound messages and template-backed delivery */
251
+ emailService?: EmailService
242
252
  /** Meta service for reading .pikku metadata files (filesystem on Node, R2/KV on CF) */
243
253
  metaService?: MetaService
254
+ /** Audit service for durable or staged audit event capture */
255
+ audit?: AuditService
244
256
  /** Session store for persisting user sessions keyed by pikkuUserId */
245
257
  sessionStore?: SessionStore
246
258
  }
@@ -263,6 +275,8 @@ export type PikkuWire<
263
275
  wireId: string
264
276
  /** Trace ID for distributed tracing — propagated across remote RPC calls via x-trace-id header */
265
277
  traceId: string
278
+ /** Function id for the current invocation */
279
+ functionId: string
266
280
  http: PikkuHTTP<In>
267
281
  mcp: PikkuMCP<MCPTools>
268
282
  rpc: TypedRPC
@@ -281,7 +295,7 @@ export type PikkuWire<
281
295
  ? UserSession
282
296
  : UserSession | undefined
283
297
  /** Update and persist the current session */
284
- setSession: (session: CoreUserSession) => Promise<void> | void
298
+ setSession: (session: UserSession) => Promise<void> | void
285
299
  /** Clear and persist the current session */
286
300
  clearSession: () => Promise<void> | void
287
301
  /** Fetch the latest session (may read from backing store) */
@@ -298,6 +312,10 @@ export type PikkuWire<
298
312
  getCredentials: () =>
299
313
  | Record<string, unknown>
300
314
  | Promise<Record<string, unknown>>
315
+ /** Resolved function audit metadata for this invocation, if enabled */
316
+ audit: {
317
+ durability: AuditDurability
318
+ }
301
319
  }>
302
320
 
303
321
  /**
@@ -16,7 +16,7 @@ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
16
16
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
17
17
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js'
18
18
  import type { SessionService } from '../../services/user-session-service.js'
19
- import { randomUUID } from 'crypto'
19
+ import { randomUUID } from './ai-agent-utils.js'
20
20
  import { streamAIAgent } from './ai-agent-stream.js'
21
21
  import { runAIAgent } from './ai-agent-runner.js'
22
22
  import {
@@ -32,6 +32,8 @@ import { resolveModelConfig } from './ai-agent-model-config.js'
32
32
 
33
33
  export type RunAIAgentParams = {
34
34
  sessionService?: SessionService<CoreUserSession>
35
+ /** Credential accessor for the current request — used to read per-user overrides (e.g. AI_API_KEY). */
36
+ getCredential?: <T = unknown>(name: string) => T | null | Promise<T | null>
35
37
  }
36
38
 
37
39
  export type StreamAIAgentOptions = {
@@ -616,11 +618,18 @@ export async function prepareAgentRun(
616
618
  const singletonServices = getSingletonServices()
617
619
  const { agent, packageName, resolvedName } = resolveAgent(agentName)
618
620
 
619
- const agentRunner = singletonServices.aiAgentRunner
621
+ let agentRunner = singletonServices.aiAgentRunner
620
622
  if (!agentRunner) {
621
623
  throw new AIProviderNotConfiguredError()
622
624
  }
623
625
 
626
+ if (params.getCredential && agentRunner.withApiKey) {
627
+ const aiCredential = await params.getCredential<{ apiKey: string }>('AI_API_KEY')
628
+ if (aiCredential?.apiKey?.trim()) {
629
+ agentRunner = agentRunner.withApiKey(aiCredential.apiKey)
630
+ }
631
+ }
632
+
624
633
  const { storage } = resolveMemoryServices(agent, singletonServices)
625
634
  const memoryConfig = agent.memory
626
635
  const threadId = input.threadId
@@ -970,3 +970,108 @@ describe('resumeAIAgentSync', () => {
970
970
  assert.equal(result.text, 'Recovered')
971
971
  })
972
972
  })
973
+
974
+ describe('getCredential API key override', () => {
975
+ test('uses withApiKey runner when getCredential returns apiKey', async () => {
976
+ addTestAgent('api-key-agent')
977
+
978
+ const originalRunCalls: string[] = []
979
+ const overrideRunCalls: string[] = []
980
+
981
+ const overrideRunner = {
982
+ run: async (): Promise<AIAgentStepResult> => {
983
+ overrideRunCalls.push('override')
984
+ return makeStepResult({ text: 'from-override', finishReason: 'stop' })
985
+ },
986
+ }
987
+
988
+ const mockServices = {
989
+ logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
990
+ aiAgentRunner: {
991
+ run: async (): Promise<AIAgentStepResult> => {
992
+ originalRunCalls.push('original')
993
+ return makeStepResult({ text: 'from-original', finishReason: 'stop' })
994
+ },
995
+ withApiKey: (_key: string) => overrideRunner,
996
+ },
997
+ aiRunState: {
998
+ createRun: async () => 'run-key-override',
999
+ updateRun: async () => {},
1000
+ },
1001
+ } as any
1002
+
1003
+ pikkuState(null, 'package', 'singletonServices', mockServices)
1004
+
1005
+ const result = await runAIAgent(
1006
+ 'api-key-agent',
1007
+ { message: 'hi', threadId: 't-key', resourceId: 'r-key' },
1008
+ { getCredential: async () => ({ apiKey: 'my-secret-key' }) }
1009
+ )
1010
+
1011
+ assert.equal(result.text, 'from-override')
1012
+ assert.equal(overrideRunCalls.length, 1)
1013
+ assert.equal(originalRunCalls.length, 0)
1014
+ })
1015
+
1016
+ test('uses original runner when getCredential returns null', async () => {
1017
+ addTestAgent('no-cred-agent')
1018
+
1019
+ const originalRunCalls: string[] = []
1020
+
1021
+ const mockServices = {
1022
+ logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
1023
+ aiAgentRunner: {
1024
+ run: async (): Promise<AIAgentStepResult> => {
1025
+ originalRunCalls.push('original')
1026
+ return makeStepResult({ text: 'from-original', finishReason: 'stop' })
1027
+ },
1028
+ withApiKey: (_key: string) => ({ run: async () => makeStepResult({ text: 'should-not-be-called', finishReason: 'stop' }) }),
1029
+ },
1030
+ aiRunState: {
1031
+ createRun: async () => 'run-no-cred',
1032
+ updateRun: async () => {},
1033
+ },
1034
+ } as any
1035
+
1036
+ pikkuState(null, 'package', 'singletonServices', mockServices)
1037
+
1038
+ const result = await runAIAgent(
1039
+ 'no-cred-agent',
1040
+ { message: 'hi', threadId: 't-no-cred', resourceId: 'r-no-cred' },
1041
+ { getCredential: async () => null }
1042
+ )
1043
+
1044
+ assert.equal(result.text, 'from-original')
1045
+ assert.equal(originalRunCalls.length, 1)
1046
+ })
1047
+
1048
+ test('uses original runner when runner does not implement withApiKey', async () => {
1049
+ addTestAgent('no-withkey-agent')
1050
+
1051
+ const originalRunCalls: string[] = []
1052
+
1053
+ const mockServices = {
1054
+ logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
1055
+ aiAgentRunner: {
1056
+ run: async (): Promise<AIAgentStepResult> => {
1057
+ originalRunCalls.push('original')
1058
+ return makeStepResult({ text: 'from-original', finishReason: 'stop' })
1059
+ },
1060
+ },
1061
+ aiRunState: {
1062
+ createRun: async () => 'run-no-withkey',
1063
+ updateRun: async () => {},
1064
+ },
1065
+ } as any
1066
+
1067
+ pikkuState(null, 'package', 'singletonServices', mockServices)
1068
+
1069
+ await runAIAgent(
1070
+ 'no-withkey-agent',
1071
+ { message: 'hi', threadId: 't-no-withkey', resourceId: 'r-no-withkey' },
1072
+ { getCredential: async () => ({ apiKey: 'ignored-key' }) }
1073
+ )
1074
+
1075
+ assert.equal(originalRunCalls.length, 1)
1076
+ })
1077
+ })
@@ -33,6 +33,7 @@ import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js'
33
33
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
34
34
  import { resolveModelConfig } from './ai-agent-model-config.js'
35
35
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
36
+ import { randomUUID } from './ai-agent-utils.js'
36
37
 
37
38
  function stripNulls(obj: unknown): unknown {
38
39
  if (obj === null) return undefined
@@ -46,7 +47,6 @@ function stripNulls(obj: unknown): unknown {
46
47
  }
47
48
  return result
48
49
  }
49
- import { randomUUID } from 'crypto'
50
50
 
51
51
  function extractStructuredAssistantOutput(object: unknown): {
52
52
  text: string | null
@@ -11,11 +11,11 @@ import type {
11
11
  } from './ai-agent.types.js'
12
12
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
13
13
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
14
+ import { randomUUID } from './ai-agent-utils.js'
14
15
  import {
15
16
  combineChannelMiddleware,
16
17
  wrapChannelWithMiddleware,
17
18
  } from '../channel/channel-middleware-runner.js'
18
- import { randomUUID } from 'crypto'
19
19
  import type { AIStorageService } from '../../services/ai-storage-service.js'
20
20
  import type {
21
21
  AIAgentRunnerParams,
@@ -0,0 +1 @@
1
+ export const randomUUID = (): string => globalThis.crypto.randomUUID()
@@ -325,6 +325,7 @@ export class ContextAwareRPCService {
325
325
  run: async (agentName: string, input: AIAgentInput) => {
326
326
  const result = await runAIAgent(agentName, input, {
327
327
  sessionService: this.options.sessionService,
328
+ getCredential: this.wire.getCredential?.bind(this.wire),
328
329
  })
329
330
  return {
330
331
  runId: result.runId,
@@ -353,7 +354,10 @@ export class ContextAwareRPCService {
353
354
  agentName,
354
355
  input,
355
356
  channel,
356
- { sessionService: this.options.sessionService },
357
+ {
358
+ sessionService: this.options.sessionService,
359
+ getCredential: this.wire.getCredential?.bind(this.wire),
360
+ },
357
361
  undefined,
358
362
  options
359
363
  )
@@ -368,7 +372,10 @@ export class ContextAwareRPCService {
368
372
  await resumeAIAgent(
369
373
  { runId, ...input },
370
374
  channel,
371
- { sessionService: this.options.sessionService },
375
+ {
376
+ sessionService: this.options.sessionService,
377
+ getCredential: this.wire.getCredential?.bind(this.wire),
378
+ },
372
379
  options
373
380
  )
374
381
  },