@pikku/core 0.12.74 → 0.12.77

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 (143) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/dist/column-form.d.ts +32 -0
  3. package/dist/column-form.js +42 -0
  4. package/dist/crypto-utils.d.ts +15 -4
  5. package/dist/crypto-utils.js +18 -2
  6. package/dist/data-classification.d.ts +44 -0
  7. package/dist/function/functions.types.d.ts +23 -10
  8. package/dist/function/index.d.ts +1 -1
  9. package/dist/index.d.ts +6 -3
  10. package/dist/index.js +3 -1
  11. package/dist/middleware/auth-bearer.js +2 -1
  12. package/dist/middleware/remote-auth.js +1 -1
  13. package/dist/remote.js +1 -1
  14. package/dist/secret-value.d.ts +56 -0
  15. package/dist/secret-value.js +46 -0
  16. package/dist/services/audit-service.d.ts +74 -4
  17. package/dist/services/audit-service.js +7 -5
  18. package/dist/services/credential-wire-service.d.ts +5 -0
  19. package/dist/services/credential-wire-service.js +9 -1
  20. package/dist/services/email-service.d.ts +2 -1
  21. package/dist/services/index.d.ts +3 -3
  22. package/dist/services/index.js +1 -1
  23. package/dist/services/local-content-request-handler.d.ts +29 -0
  24. package/dist/services/local-content-request-handler.js +176 -0
  25. package/dist/services/local-secrets.d.ts +4 -3
  26. package/dist/services/local-secrets.js +7 -3
  27. package/dist/services/logger.d.ts +22 -5
  28. package/dist/services/queue-webhook-service.js +1 -1
  29. package/dist/services/scoped-secret-service.d.ts +4 -3
  30. package/dist/services/secret-service.d.ts +8 -3
  31. package/dist/services/typed-secret-service.d.ts +5 -4
  32. package/dist/services/webhook-service.d.ts +2 -1
  33. package/dist/testing/service-tests.js +6 -6
  34. package/dist/types/core.types.d.ts +25 -4
  35. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  36. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  37. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  38. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  39. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  40. package/dist/wirings/ai-agent/index.d.ts +1 -1
  41. package/dist/wirings/ai-agent/index.js +1 -1
  42. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  43. package/dist/wirings/ai-agent/voice-input.js +44 -9
  44. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  45. package/dist/wirings/ai-agent/voice-output.js +10 -1
  46. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  47. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  48. package/dist/wirings/cli/channel/index.d.ts +1 -0
  49. package/dist/wirings/persona/define-personas.d.ts +4 -0
  50. package/dist/wirings/persona/define-personas.js +4 -0
  51. package/dist/wirings/persona/persona.types.d.ts +11 -0
  52. package/dist/wirings/queue/queue-identity.js +2 -1
  53. package/dist/wirings/queue/queue.types.d.ts +2 -1
  54. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  55. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  56. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  57. package/dist/wirings/virtual-user/index.d.ts +3 -0
  58. package/dist/wirings/virtual-user/index.js +2 -0
  59. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  60. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  61. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  62. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  63. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  64. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  65. package/dist/wirings/workflow/scenario-prose.js +1 -1
  66. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  67. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  68. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  69. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  70. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  71. package/knowledge/decisions/internals/index.md +4 -0
  72. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  73. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  74. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  75. package/package.json +3 -2
  76. package/src/column-form.test.ts +97 -0
  77. package/src/column-form.ts +58 -0
  78. package/src/crypto-utils.ts +25 -6
  79. package/src/data-classification.ts +44 -0
  80. package/src/function/functions.types.ts +47 -10
  81. package/src/function/index.ts +1 -0
  82. package/src/index.ts +24 -2
  83. package/src/middleware/auth-bearer.test.ts +3 -2
  84. package/src/middleware/auth-bearer.ts +2 -1
  85. package/src/middleware/remote-auth.test.ts +2 -1
  86. package/src/middleware/remote-auth.ts +1 -1
  87. package/src/remote.test.ts +2 -1
  88. package/src/remote.ts +1 -1
  89. package/src/secret-value.test.ts +204 -0
  90. package/src/secret-value.ts +111 -0
  91. package/src/services/audit-service.ts +87 -9
  92. package/src/services/credential-wire-service.ts +9 -1
  93. package/src/services/email-service.ts +3 -1
  94. package/src/services/index.ts +3 -3
  95. package/src/services/local-content-request-handler.test.ts +202 -0
  96. package/src/services/local-content-request-handler.ts +267 -0
  97. package/src/services/local-secrets.test.ts +20 -5
  98. package/src/services/local-secrets.ts +15 -7
  99. package/src/services/logger.ts +27 -7
  100. package/src/services/queue-webhook-service.test.ts +2 -1
  101. package/src/services/queue-webhook-service.ts +1 -1
  102. package/src/services/scoped-secret-service.ts +4 -3
  103. package/src/services/secret-service.ts +8 -3
  104. package/src/services/typed-secret-service.ts +11 -7
  105. package/src/services/webhook-service.ts +4 -1
  106. package/src/testing/service-tests.ts +6 -6
  107. package/src/types/core.types.ts +25 -4
  108. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  109. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  110. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  111. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  112. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  113. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  114. package/src/wirings/ai-agent/index.ts +2 -0
  115. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  116. package/src/wirings/ai-agent/voice-input.ts +48 -9
  117. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  118. package/src/wirings/ai-agent/voice-output.ts +28 -1
  119. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  120. package/src/wirings/cli/channel/index.ts +4 -0
  121. package/src/wirings/persona/define-personas.ts +4 -0
  122. package/src/wirings/persona/persona.types.ts +11 -0
  123. package/src/wirings/queue/queue-identity.test.ts +2 -1
  124. package/src/wirings/queue/queue-identity.ts +4 -1
  125. package/src/wirings/queue/queue.types.ts +6 -1
  126. package/src/wirings/queue/signed-queue-service.ts +2 -1
  127. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  128. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  129. package/src/wirings/virtual-user/index.ts +12 -0
  130. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  131. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  132. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  133. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  134. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  135. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  136. package/src/wirings/workflow/scenario-prose.ts +1 -1
  137. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  138. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  139. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  140. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  141. package/src/wirings/workflow/workflow.types.ts +7 -0
  142. package/tsconfig.tsbuildinfo +1 -1
  143. package/tsconfig.type-tests.json +12 -0
@@ -7,14 +7,14 @@ describe('LocalSecretService', () => {
7
7
  test('should get secret from local storage', async () => {
8
8
  const service = new LocalSecretService()
9
9
  await service.setSecret('MY_KEY', { token: 'abc' })
10
- const result = await service.getSecret('MY_KEY')
10
+ const result = (await service.getSecret('MY_KEY')).reveal()
11
11
  assert.deepStrictEqual(result, { token: 'abc' })
12
12
  })
13
13
 
14
14
  test('should get secret from environment variables', async () => {
15
15
  const vars = new LocalVariablesService({ MY_SECRET: 'secret-value' })
16
16
  const service = new LocalSecretService(vars)
17
- const result = await service.getSecret('MY_SECRET')
17
+ const result = (await service.getSecret('MY_SECRET')).reveal()
18
18
  assert.strictEqual(result, 'secret-value')
19
19
  })
20
20
 
@@ -29,14 +29,14 @@ describe('LocalSecretService', () => {
29
29
  test('should get JSON secret from local storage', async () => {
30
30
  const service = new LocalSecretService()
31
31
  await service.setSecret('JSON_KEY', { data: 42 })
32
- const result = await service.getSecret('JSON_KEY')
32
+ const result = (await service.getSecret('JSON_KEY')).reveal()
33
33
  assert.deepStrictEqual(result, { data: 42 })
34
34
  })
35
35
 
36
36
  test('should get JSON secret from environment variables', async () => {
37
37
  const vars = new LocalVariablesService({ CONFIG: '{"port":3000}' })
38
38
  const service = new LocalSecretService(vars)
39
- const result = await service.getSecret('CONFIG')
39
+ const result = (await service.getSecret('CONFIG')).reveal()
40
40
  assert.deepStrictEqual(result, { port: 3000 })
41
41
  })
42
42
 
@@ -52,7 +52,7 @@ describe('LocalSecretService', () => {
52
52
  const vars = new LocalVariablesService({ KEY: 'env-value' })
53
53
  const service = new LocalSecretService(vars)
54
54
  await service.setSecret('KEY', 'local-value')
55
- const result = await service.getSecret('KEY')
55
+ const result = (await service.getSecret('KEY')).reveal()
56
56
  assert.strictEqual(result, 'local-value')
57
57
  })
58
58
 
@@ -78,3 +78,18 @@ describe('LocalSecretService', () => {
78
78
  assert.strictEqual(await service.hasSecret('DEL_KEY'), false)
79
79
  })
80
80
  })
81
+
82
+ describe('LocalSecretService round-trips a wrapped secret', () => {
83
+ test('setSecret unwraps rather than storing the redaction', async () => {
84
+ const service = new LocalSecretService()
85
+ await service.setSecret('SOURCE', 'sk-live-DEADBEEF')
86
+ const read = await service.getSecret('SOURCE')
87
+
88
+ await service.setSecret('COPY', read)
89
+
90
+ assert.strictEqual(
91
+ (await service.getSecret('COPY')).reveal(),
92
+ 'sk-live-DEADBEEF'
93
+ )
94
+ })
95
+ })
@@ -1,5 +1,10 @@
1
+ import {
2
+ createSecretValue,
3
+ isSecretValue,
4
+ type SecretValue,
5
+ } from '../secret-value.js'
1
6
  import { LocalVariablesService } from './local-variables.js'
2
- import type { SecretService } from './secret-service.js'
7
+ import type { SecretService, SecretValues } from './secret-service.js'
3
8
  import type { VariablesService } from './variables-service.js'
4
9
 
5
10
  export class LocalSecretService implements SecretService {
@@ -17,23 +22,26 @@ export class LocalSecretService implements SecretService {
17
22
  private variables: VariablesService = new LocalVariablesService()
18
23
  ) {}
19
24
 
20
- public async getSecret<T = string>(key: string): Promise<T> {
25
+ public async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
21
26
  const localValue = this.localSecrets.get(key)
22
27
  if (localValue) {
23
- return this.parseSecret<T>(localValue)
28
+ return createSecretValue(this.parseSecret<T>(localValue))
24
29
  }
25
30
 
26
31
  const value = await this.variables.get(key)
27
32
  if (value) {
28
- return this.parseSecret<T>(value)
33
+ return createSecretValue(this.parseSecret<T>(value))
29
34
  }
30
35
  throw new Error('Requested secret not found')
31
36
  }
32
37
 
33
38
  public async setSecret(key: string, value: unknown): Promise<void> {
39
+ // Storing the wrapper would serialize it to '[secret]', so unwrap first —
40
+ // writing a secret back to the vault is exactly what this method is for.
41
+ const raw = isSecretValue(value) ? value.reveal() : value
34
42
  this.localSecrets.set(
35
43
  key,
36
- typeof value === 'string' ? value : JSON.stringify(value)
44
+ typeof raw === 'string' ? raw : JSON.stringify(raw)
37
45
  )
38
46
  }
39
47
 
@@ -51,13 +59,13 @@ export class LocalSecretService implements SecretService {
51
59
 
52
60
  public async getSecrets<
53
61
  T extends Record<string, unknown> = Record<string, unknown>,
54
- >(keys: (keyof T & string)[]): Promise<Partial<T>> {
62
+ >(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>> {
55
63
  const results = await Promise.allSettled(keys.map((k) => this.getSecret(k)))
56
64
  const out: Record<string, unknown> = {}
57
65
  keys.forEach((key, i) => {
58
66
  if (results[i].status === 'fulfilled')
59
67
  out[key] = (results[i] as PromiseFulfilledResult<unknown>).value
60
68
  })
61
- return out as Partial<T>
69
+ return out as Partial<SecretValues<T>>
62
70
  }
63
71
  }
@@ -1,3 +1,5 @@
1
+ import type { Safe } from '../secret-value.js'
2
+
1
3
  export enum LogLevel {
2
4
  'trace',
3
5
  'debug',
@@ -7,19 +9,37 @@ export enum LogLevel {
7
9
  'critical',
8
10
  }
9
11
 
12
+ /**
13
+ * A log line is the easiest place to leak a vault secret, so every parameter is
14
+ * `Safe<>`-guarded: a `SecretValue` anywhere in the message or the metadata,
15
+ * however deeply nested, collapses to `never` and fails the build. Reveal it
16
+ * first if you genuinely mean to log it.
17
+ */
10
18
  export interface Logger {
11
- info(messageOrObj: string | Record<string, any>, ...meta: any[]): void
19
+ info<M extends string | Record<string, any>, A extends unknown[]>(
20
+ messageOrObj: Safe<M>,
21
+ ...meta: { [K in keyof A]: Safe<A[K]> }
22
+ ): void
12
23
 
13
- warn(messageOrObj: string | Record<string, any>, ...meta: any[]): void
24
+ warn<M extends string | Record<string, any>, A extends unknown[]>(
25
+ messageOrObj: Safe<M>,
26
+ ...meta: { [K in keyof A]: Safe<A[K]> }
27
+ ): void
14
28
 
15
- error(
16
- messageOrObj: string | Record<string, any> | Error,
17
- ...meta: any[]
29
+ error<M extends string | Record<string, any> | Error, A extends unknown[]>(
30
+ messageOrObj: Safe<M>,
31
+ ...meta: { [K in keyof A]: Safe<A[K]> }
18
32
  ): void
19
33
 
20
- debug(message: string, ...meta: any[]): void
34
+ debug<A extends unknown[]>(
35
+ message: string,
36
+ ...meta: { [K in keyof A]: Safe<A[K]> }
37
+ ): void
21
38
 
22
- trace?(message: string, ...meta: any[]): void
39
+ trace?<A extends unknown[]>(
40
+ message: string,
41
+ ...meta: { [K in keyof A]: Safe<A[K]> }
42
+ ): void
23
43
 
24
44
  setLevel(level: LogLevel): void
25
45
 
@@ -14,6 +14,7 @@ import { InMemoryQueueService } from './in-memory-queue-service.js'
14
14
  import { wireQueueWorker } from '../wirings/queue/queue-runner.js'
15
15
  import { resetPikkuState, pikkuState } from '../pikku-state.js'
16
16
  import type { JobOptions } from '../wirings/queue/queue.types.js'
17
+ import { createSecretValue } from '../secret-value.js'
17
18
 
18
19
  const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
19
20
 
@@ -132,7 +133,7 @@ describe('QueueWebhookService.send', () => {
132
133
  secrets: {
133
134
  getSecret: async (key: string) => {
134
135
  assert.equal(key, 'WEBHOOK_SIGNING_KEY')
135
- return 'resolved-key'
136
+ return createSecretValue('resolved-key')
136
137
  },
137
138
  },
138
139
  })
@@ -52,7 +52,7 @@ export class QueueWebhookService extends WebhookService {
52
52
  let secret = input.secret
53
53
  if (secret === undefined && webhookConfig?.secret) {
54
54
  // Naive read: caching is the secret service's concern, not ours.
55
- secret = await services.secrets.getSecret(webhookConfig.secret)
55
+ secret = (await services.secrets.getSecret(webhookConfig.secret)).reveal()
56
56
  if (!secret) {
57
57
  services.logger.error(
58
58
  `Webhook signing secret '${webhookConfig.secret}' (config.webhook.secret) resolved to nothing — outgoing webhooks will be sent UNSIGNED.`
@@ -1,4 +1,5 @@
1
- import type { SecretService } from './secret-service.js'
1
+ import type { SecretValue } from '../secret-value.js'
2
+ import type { SecretService, SecretValues } from './secret-service.js'
2
3
 
3
4
  export class ScopedSecretService implements SecretService {
4
5
  constructor(
@@ -12,7 +13,7 @@ export class ScopedSecretService implements SecretService {
12
13
  }
13
14
  }
14
15
 
15
- async getSecret<T = string>(key: string): Promise<T> {
16
+ async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
16
17
  this.assertAllowed(key)
17
18
  return this.secrets.getSecret<T>(key)
18
19
  }
@@ -32,7 +33,7 @@ export class ScopedSecretService implements SecretService {
32
33
 
33
34
  async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
34
35
  keys: (keyof T & string)[]
35
- ): Promise<Partial<T>> {
36
+ ): Promise<Partial<SecretValues<T>>> {
36
37
  keys.forEach((k) => this.assertAllowed(k))
37
38
  return this.secrets.getSecrets<T>(keys)
38
39
  }
@@ -1,6 +1,11 @@
1
+ import type { SecretValue } from '../secret-value.js'
2
+
3
+ /** A record of secrets, each still wrapped. */
4
+ export type SecretValues<T> = { [K in keyof T]: SecretValue<T[K]> }
5
+
1
6
  export interface SecretService {
2
- /** Throws if the secret is not found. */
3
- getSecret<T = string>(key: string): Promise<T>
7
+ /** Throws if the secret is not found. Unwrap the result with `.reveal()`. */
8
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>
4
9
  /** Answers for any key, including a disallowed one — it must not throw. */
5
10
  hasSecret(key: string): Promise<boolean>
6
11
  setSecret(key: string, value: unknown): Promise<void>
@@ -12,5 +17,5 @@ export interface SecretService {
12
17
  */
13
18
  getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
14
19
  keys: (keyof T & string)[]
15
- ): Promise<Partial<T>>
20
+ ): Promise<Partial<SecretValues<T>>>
16
21
  }
@@ -1,4 +1,5 @@
1
- import type { SecretService } from './secret-service.js'
1
+ import type { SecretValue } from '../secret-value.js'
2
+ import type { SecretService, SecretValues } from './secret-service.js'
2
3
 
3
4
  export interface CredentialStatus {
4
5
  secretId: string
@@ -25,8 +26,10 @@ export class TypedSecretService<
25
26
  private credentialsMeta: Record<string, CredentialMeta>
26
27
  ) {}
27
28
 
28
- async getSecret<K extends keyof TMap & string>(key: K): Promise<TMap[K]>
29
- async getSecret<T = string>(key: string): Promise<T>
29
+ async getSecret<K extends keyof TMap & string>(
30
+ key: K
31
+ ): Promise<SecretValue<TMap[K]>>
32
+ async getSecret<T = string>(key: string): Promise<SecretValue<T>>
30
33
  async getSecret(key: string): Promise<unknown> {
31
34
  if (this.cache.has(key)) {
32
35
  return this.cache.get(key)
@@ -58,12 +61,12 @@ export class TypedSecretService<
58
61
 
59
62
  async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
60
63
  keys: (keyof T & string)[]
61
- ): Promise<Partial<T>> {
62
- const result: Partial<T> = {}
64
+ ): Promise<Partial<SecretValues<T>>> {
65
+ const result: Partial<SecretValues<T>> = {}
63
66
  const missing: (keyof T & string)[] = []
64
67
  for (const key of keys) {
65
68
  if (this.cache.has(key)) {
66
- result[key] = this.cache.get(key) as T[keyof T & string]
69
+ result[key] = this.cache.get(key) as SecretValues<T>[keyof T & string]
67
70
  } else {
68
71
  missing.push(key)
69
72
  }
@@ -72,7 +75,8 @@ export class TypedSecretService<
72
75
  const fetched = await this.secrets.getSecrets<T>(missing)
73
76
  for (const [key, value] of Object.entries(fetched)) {
74
77
  this.cache.set(key, value)
75
- result[key as keyof T & string] = value as T[keyof T & string]
78
+ result[key as keyof T & string] = value as SecretValues<T>[keyof T &
79
+ string]
76
80
  }
77
81
  }
78
82
  return result
@@ -1,5 +1,6 @@
1
1
  import { NotImplementedError } from '../errors/errors.js'
2
2
  import { hmacSha256Hex, timingSafeStringEqual } from '../utils/hmac.js'
3
+ import type { Safe } from '../secret-value.js'
3
4
 
4
5
  export interface SendWebhookInput {
5
6
  url: string
@@ -90,7 +91,9 @@ export const DEFAULT_WEBHOOK_SIGNATURE_HEADER = 'X-Pikku-Signature'
90
91
  export const DEFAULT_WEBHOOK_RETRIES = 3
91
92
 
92
93
  export abstract class WebhookService {
93
- abstract send(input: SendWebhookInput): Promise<SendWebhookResult>
94
+ abstract send<T extends SendWebhookInput>(
95
+ input: Safe<T>
96
+ ): Promise<SendWebhookResult>
94
97
 
95
98
  /** Produces the header value, `sha256=<hex>`, not the bare digest. */
96
99
  protected sign(secret: string, body: string): string {
@@ -831,17 +831,17 @@ export function defineServiceTests(config: ServiceTestConfig): void {
831
831
  token: string
832
832
  endpoint: string
833
833
  }>('api-key')
834
- assert.deepEqual(result, {
834
+ assert.deepEqual(result.reveal(), {
835
835
  token: 'sk-123',
836
836
  endpoint: 'https://api.example.com',
837
837
  })
838
838
  })
839
839
 
840
- test('getSecret returns raw string', async () => {
840
+ test('getSecret wraps the raw string rather than returning it', async () => {
841
841
  const service = await factory({ key: kek })
842
842
  await service.setSecret('string-secret', 'plain-value')
843
843
  const result = await service.getSecret('string-secret')
844
- assert.strictEqual(result, 'plain-value')
844
+ assert.strictEqual(result.reveal(), 'plain-value')
845
845
  })
846
846
 
847
847
  test('hasSecret returns true/false', async () => {
@@ -862,7 +862,7 @@ export function defineServiceTests(config: ServiceTestConfig): void {
862
862
  await service.setSecret('upsert-key', { v: 1 })
863
863
  await service.setSecret('upsert-key', { v: 2 })
864
864
  const result = await service.getSecret<{ v: number }>('upsert-key')
865
- assert.deepEqual(result, { v: 2 })
865
+ assert.deepEqual(result.reveal(), { v: 2 })
866
866
  })
867
867
 
868
868
  test('deleteSecret removes the key', async () => {
@@ -887,7 +887,7 @@ export function defineServiceTests(config: ServiceTestConfig): void {
887
887
  const before = await rotatedService.getSecret<{
888
888
  important: string
889
889
  }>('rotate-test')
890
- assert.deepEqual(before, { important: 'data' })
890
+ assert.deepEqual(before.reveal(), { important: 'data' })
891
891
 
892
892
  assert.ok(rotatedService.rotateKEK)
893
893
  const count = await rotatedService.rotateKEK!()
@@ -900,7 +900,7 @@ export function defineServiceTests(config: ServiceTestConfig): void {
900
900
  const after = await newOnlyService.getSecret<{
901
901
  important: string
902
902
  }>('rotate-test')
903
- assert.deepEqual(after, { important: 'data' })
903
+ assert.deepEqual(after.reveal(), { important: 'data' })
904
904
  })
905
905
 
906
906
  test('rotateKEK throws without previousKey', async () => {
@@ -44,6 +44,7 @@ import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.j
44
44
  import type { AIEmbeddingService } from '../services/ai-embedding-service.js'
45
45
  import type { AIRunStateService } from '../services/ai-run-state-service.js'
46
46
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js'
47
+ import type { VirtualUserRunStore } from '../wirings/virtual-user/virtual-user-run-store.js'
47
48
  import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.js'
48
49
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js'
49
50
  import type { CredentialService } from '../services/credential-service.js'
@@ -125,11 +126,12 @@ export type FunctionRuntimeMeta = {
125
126
  */
126
127
  auth?: boolean
127
128
  /**
128
- * The author's declaration that this function authorizes callers in its own
129
- * body. Carries no runtime effect — it records a gate codegen cannot see, so
130
- * an audit is not left to guess whether a sessionless function is open.
129
+ * The author's declaration that this function's permission check lives in its
130
+ * own body. Carries no runtime effect — it records a gate codegen cannot see,
131
+ * so an audit is not left to guess whether a sessionless function is open.
132
+ * Refused unless `allow.permissionsInBody` is set in `pikku.config.json`.
131
133
  */
132
- selfAuthenticated?: boolean
134
+ permissionsInBody?: boolean
133
135
  remote?: boolean
134
136
  /**
135
137
  * A step RPC: invoked by name only from a scenario run and refused
@@ -173,6 +175,19 @@ export type FunctionRuntimeMeta = {
173
175
  scenarioStepAddon?: string
174
176
  /** Scenario steps only: the prose a reporter renders, with `{placeholders}` filled from the step's recorded input. */
175
177
  scenarioStepTemplate?: string
178
+ /**
179
+ * The function's `audit` config, resolved — `audit: true` reads as
180
+ * `{ durability: 'best-effort' }`. Absent means the function records nothing:
181
+ * `auditLog.write()` from an unmarked function is dropped with a warning, so
182
+ * this is the only place a reader can see which functions have a trail at all
183
+ * without running them.
184
+ *
185
+ * Informational. The runner resolves audit from the live function config, not
186
+ * from here, so meta and runtime cannot disagree about whether audit is on.
187
+ */
188
+ audit?: {
189
+ durability: AuditDurability
190
+ }
176
191
  /** Keeps the full `SecretService`. Set by the inspector, read by the runner. */
177
192
  secretBroker?: boolean
178
193
  version?: number
@@ -323,6 +338,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
323
338
  */
324
339
  webhookService?: WebhookService
325
340
  metaService?: MetaService
341
+ /**
342
+ * Where virtual-user runs are recorded. A run is dispatched and answered for
343
+ * later, so this store is the only trace it leaves — see
344
+ * {@link VirtualUserRunStore}.
345
+ */
346
+ virtualUserRunStore?: VirtualUserRunStore
326
347
  /** V8 precise-coverage collector (`pikku dev --coverage` only) */
327
348
  coverageService?: CoverageService
328
349
  audit?: AuditService
@@ -590,6 +590,22 @@ describe('wrapChannelWithAGUI — speech', () => {
590
590
  assert.ok(find(events, 'CUSTOM', 'pikku:audio-done'))
591
591
  })
592
592
 
593
+ it('forwards the transcript as a CUSTOM event', () => {
594
+ const { channel, events } = makeChannel()
595
+ const wrapped = wrapChannelWithAGUI(channel)
596
+
597
+ wrapped.send({
598
+ type: 'transcript',
599
+ text: 'what the user said',
600
+ } as AIStreamEvent)
601
+
602
+ // The client sent audio, so this is the only way it learns what its own
603
+ // turn was. Dropped, the user's message renders as an empty bubble.
604
+ const custom = find(events, 'CUSTOM', 'pikku:transcript')
605
+ assert.ok(custom)
606
+ assert.deepEqual(custom.value, { text: 'what the user said' })
607
+ })
608
+
593
609
  it('leaves an open text message open, so speech cannot split a reply', () => {
594
610
  const { channel, events } = makeChannel()
595
611
  const wrapped = wrapChannelWithAGUI(channel)
@@ -381,7 +381,11 @@ export function wrapChannelWithAGUI(
381
381
  send({
382
382
  type: 'CUSTOM',
383
383
  name: 'pikku:audio-delta',
384
- value: { data: event.data, format: event.format },
384
+ value: {
385
+ data: event.data,
386
+ format: event.format,
387
+ ...(event.text === undefined ? {} : { text: event.text }),
388
+ },
385
389
  })
386
390
  break
387
391
  }
@@ -390,6 +394,15 @@ export function wrapChannelWithAGUI(
390
394
  send({ type: 'CUSTOM', name: 'pikku:audio-done', value: {} })
391
395
  break
392
396
  }
397
+
398
+ case 'transcript': {
399
+ send({
400
+ type: 'CUSTOM',
401
+ name: 'pikku:transcript',
402
+ value: { text: event.text },
403
+ })
404
+ break
405
+ }
393
406
  }
394
407
  },
395
408
  }
@@ -959,7 +959,13 @@ export async function prepareAgentRun(
959
959
 
960
960
  const userContent: AIMessage['content'] = input.attachments?.length
961
961
  ? [
962
- { type: 'text' as const, text: input.message },
962
+ // Omitted when there is nothing to say. An attachment on its own is a
963
+ // real turn — a spoken one carries audio and no text at all — and an
964
+ // empty text part alongside it is a part providers are entitled to
965
+ // reject, for a caller who never wrote one.
966
+ ...(input.message
967
+ ? [{ type: 'text' as const, text: input.message }]
968
+ : []),
963
969
  ...input.attachments.map(
964
970
  (a) =>
965
971
  ({
@@ -144,6 +144,9 @@ export async function runAIAgent(
144
144
  ...(agent.aiMiddleware ?? []),
145
145
  ]
146
146
 
147
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
148
+ const sharedNotes: Record<string, unknown> = {}
149
+
147
150
  let modifiedMessages = runnerParams.messages
148
151
  let modifiedInstructions = runnerParams.instructions
149
152
  for (const mw of aiMiddlewares) {
@@ -151,6 +154,7 @@ export async function runAIAgent(
151
154
  const result = await mw.modifyInput(singletonServices, {
152
155
  messages: modifiedMessages,
153
156
  instructions: modifiedInstructions,
157
+ shared: sharedNotes,
154
158
  })
155
159
  modifiedMessages = result.messages
156
160
  modifiedInstructions = result.instructions
@@ -159,6 +163,14 @@ export async function runAIAgent(
159
163
  runnerParams.messages = modifiedMessages
160
164
  runnerParams.instructions = modifiedInstructions
161
165
 
166
+ // History records what the model was asked, which for a spoken turn is the
167
+ // transcript rather than the base64 audio that arrived — see the same note on
168
+ // the streaming path. Identity-checked, because a middleware may legitimately
169
+ // replace the message list with something unrelated to this turn.
170
+ const lastModified = modifiedMessages[modifiedMessages.length - 1]
171
+ const persistedUserMessage =
172
+ lastModified?.id === userMessage.id ? lastModified : userMessage
173
+
162
174
  const runId = await aiRunState.createRun({
163
175
  agentName,
164
176
  threadId,
@@ -275,7 +287,7 @@ export async function runAIAgent(
275
287
  threadId,
276
288
  input.resourceId,
277
289
  memoryConfig,
278
- userMessage,
290
+ persistedUserMessage,
279
291
  { text: '', steps: completedStepsForSave }
280
292
  )
281
293
 
@@ -351,7 +363,7 @@ export async function runAIAgent(
351
363
  threadId,
352
364
  input.resourceId,
353
365
  memoryConfig,
354
- userMessage,
366
+ persistedUserMessage,
355
367
  {
356
368
  ...result,
357
369
  text: outputText,
@@ -608,6 +620,9 @@ async function continueAfterToolResultSync(
608
620
  }),
609
621
  ...(agent.aiMiddleware ?? []),
610
622
  ]
623
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
624
+ const sharedNotes: Record<string, unknown> = {}
625
+
611
626
  let modifiedMessages = trimmedMessages
612
627
  let modifiedInstructions = instructions
613
628
  for (const mw of aiMiddlewares) {
@@ -615,6 +630,7 @@ async function continueAfterToolResultSync(
615
630
  const result = await mw.modifyInput(singletonServices, {
616
631
  messages: modifiedMessages,
617
632
  instructions: modifiedInstructions,
633
+ shared: sharedNotes,
618
634
  })
619
635
  modifiedMessages = result.messages
620
636
  modifiedInstructions = result.instructions
@@ -12,6 +12,7 @@ import type {
12
12
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
13
13
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
14
14
  import { randomUUID } from './ai-agent-utils.js'
15
+ import { SPOKEN_TRANSCRIPT } from './voice-input.js'
15
16
  import {
16
17
  combineChannelMiddleware,
17
18
  wrapChannelWithMiddleware,
@@ -672,6 +673,9 @@ export async function streamAIAgent(
672
673
  ...(agent.aiMiddleware ?? []),
673
674
  ]
674
675
 
676
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
677
+ const sharedNotes: Record<string, unknown> = {}
678
+
675
679
  let modifiedMessages = runnerParams.messages
676
680
  let modifiedInstructions = runnerParams.instructions
677
681
  for (const mw of aiMiddlewares) {
@@ -679,6 +683,7 @@ export async function streamAIAgent(
679
683
  const result = await mw.modifyInput(singletonServices, {
680
684
  messages: modifiedMessages,
681
685
  instructions: modifiedInstructions,
686
+ shared: sharedNotes,
682
687
  })
683
688
  modifiedMessages = result.messages
684
689
  modifiedInstructions = result.instructions
@@ -687,6 +692,26 @@ export async function streamAIAgent(
687
692
  runnerParams.messages = modifiedMessages
688
693
  runnerParams.instructions = modifiedInstructions
689
694
 
695
+ // Sent on the raw channel, ahead of the run. A voice client sent audio and so
696
+ // has no idea what it said; until this arrives its own message is a blank
697
+ // bubble. Ahead of the run rather than alongside it because the answer starts
698
+ // streaming within a few hundred milliseconds, and a question that appears
699
+ // after its answer reads as the wrong question.
700
+ const transcript = sharedNotes[SPOKEN_TRANSCRIPT]
701
+ if (typeof transcript === 'string') {
702
+ channel.send({ type: 'transcript', text: transcript })
703
+ }
704
+
705
+ // What goes into thread history is what the model was actually asked. For a
706
+ // spoken turn that is not what arrived over the wire: the wire carried a
707
+ // base64 audio blob, and persisting it would write megabytes of unreadable
708
+ // data into the history while losing the only readable record of the turn.
709
+ // Identity-checked rather than assumed — a middleware is free to rewrite the
710
+ // message list into something with no relation to the turn.
711
+ const lastModified = modifiedMessages[modifiedMessages.length - 1]
712
+ const persistedUserMessage =
713
+ lastModified?.id === userMessage.id ? lastModified : userMessage
714
+
690
715
  const runId = await aiRunState.createRun({
691
716
  agentName,
692
717
  threadId,
@@ -706,7 +731,7 @@ export async function streamAIAgent(
706
731
  runnerParams.tools = trackToolExecution(runnerParams.tools, interruptHandle)
707
732
 
708
733
  if (storage) {
709
- await storage.saveMessages(threadId, [userMessage])
734
+ await storage.saveMessages(threadId, [persistedUserMessage])
710
735
  }
711
736
 
712
737
  const streamMiddleware = aiMiddlewares
@@ -720,6 +745,7 @@ export async function streamAIAgent(
720
745
  event,
721
746
  allEvents,
722
747
  state,
748
+ shared: sharedNotes,
723
749
  // Sends downstream directly, so a hook can hand back the fast event
724
750
  // now and push the slow one when it is ready.
725
751
  emit: next,
@@ -1288,6 +1314,9 @@ async function continueAfterToolResult(
1288
1314
  }),
1289
1315
  ...(agent.aiMiddleware ?? []),
1290
1316
  ]
1317
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
1318
+ const sharedNotes: Record<string, unknown> = {}
1319
+
1291
1320
  let modifiedMessages = trimmedMessages
1292
1321
  let modifiedInstructions = instructions
1293
1322
  for (const mw of aiMiddlewares) {
@@ -1295,6 +1324,7 @@ async function continueAfterToolResult(
1295
1324
  const result = await mw.modifyInput(singletonServices, {
1296
1325
  messages: modifiedMessages,
1297
1326
  instructions: modifiedInstructions,
1327
+ shared: sharedNotes,
1298
1328
  })
1299
1329
  modifiedMessages = result.messages
1300
1330
  modifiedInstructions = result.instructions
@@ -1318,6 +1348,7 @@ async function continueAfterToolResult(
1318
1348
  event,
1319
1349
  allEvents,
1320
1350
  state,
1351
+ shared: sharedNotes,
1321
1352
  // Sends downstream directly, so a hook can hand back the fast event
1322
1353
  // now and push the slow one when it is ready.
1323
1354
  emit: next,