@pikku/core 0.12.74 → 0.12.78

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 (150) hide show
  1. package/CHANGELOG.md +397 -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/in-memory-workflow-service.d.ts +1 -0
  22. package/dist/services/in-memory-workflow-service.js +20 -0
  23. package/dist/services/index.d.ts +3 -3
  24. package/dist/services/index.js +1 -1
  25. package/dist/services/local-content-request-handler.d.ts +29 -0
  26. package/dist/services/local-content-request-handler.js +176 -0
  27. package/dist/services/local-secrets.d.ts +4 -3
  28. package/dist/services/local-secrets.js +7 -3
  29. package/dist/services/logger.d.ts +22 -5
  30. package/dist/services/queue-webhook-service.js +1 -1
  31. package/dist/services/scoped-secret-service.d.ts +4 -3
  32. package/dist/services/secret-service.d.ts +8 -3
  33. package/dist/services/typed-secret-service.d.ts +5 -4
  34. package/dist/services/webhook-service.d.ts +2 -1
  35. package/dist/testing/service-tests.js +6 -6
  36. package/dist/types/core.types.d.ts +25 -4
  37. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  38. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  39. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  40. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  41. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  42. package/dist/wirings/ai-agent/index.d.ts +1 -1
  43. package/dist/wirings/ai-agent/index.js +1 -1
  44. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  45. package/dist/wirings/ai-agent/voice-input.js +44 -9
  46. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  47. package/dist/wirings/ai-agent/voice-output.js +10 -1
  48. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  49. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  50. package/dist/wirings/cli/channel/index.d.ts +1 -0
  51. package/dist/wirings/persona/define-personas.d.ts +4 -0
  52. package/dist/wirings/persona/define-personas.js +4 -0
  53. package/dist/wirings/persona/persona.types.d.ts +11 -0
  54. package/dist/wirings/queue/queue-identity.js +2 -1
  55. package/dist/wirings/queue/queue.types.d.ts +2 -1
  56. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  57. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  58. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  59. package/dist/wirings/virtual-user/index.d.ts +3 -0
  60. package/dist/wirings/virtual-user/index.js +2 -0
  61. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  62. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  63. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  64. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  65. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  66. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  67. package/dist/wirings/workflow/pikku-workflow-service.d.ts +35 -0
  68. package/dist/wirings/workflow/pikku-workflow-service.js +51 -0
  69. package/dist/wirings/workflow/scenario-prose.js +1 -1
  70. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  71. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  72. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  73. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  74. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  75. package/knowledge/decisions/internals/index.md +4 -0
  76. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  77. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  78. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  79. package/package.json +3 -2
  80. package/src/column-form.test.ts +97 -0
  81. package/src/column-form.ts +58 -0
  82. package/src/crypto-utils.ts +25 -6
  83. package/src/data-classification.ts +44 -0
  84. package/src/function/functions.types.ts +47 -10
  85. package/src/function/index.ts +1 -0
  86. package/src/index.ts +24 -2
  87. package/src/middleware/auth-bearer.test.ts +3 -2
  88. package/src/middleware/auth-bearer.ts +2 -1
  89. package/src/middleware/remote-auth.test.ts +2 -1
  90. package/src/middleware/remote-auth.ts +1 -1
  91. package/src/remote.test.ts +2 -1
  92. package/src/remote.ts +1 -1
  93. package/src/secret-value.test.ts +204 -0
  94. package/src/secret-value.ts +111 -0
  95. package/src/services/audit-service.ts +87 -9
  96. package/src/services/credential-wire-service.ts +9 -1
  97. package/src/services/email-service.ts +3 -1
  98. package/src/services/in-memory-workflow-service.ts +29 -0
  99. package/src/services/index.ts +3 -3
  100. package/src/services/local-content-request-handler.test.ts +202 -0
  101. package/src/services/local-content-request-handler.ts +267 -0
  102. package/src/services/local-secrets.test.ts +20 -5
  103. package/src/services/local-secrets.ts +15 -7
  104. package/src/services/logger.ts +27 -7
  105. package/src/services/queue-webhook-service.test.ts +2 -1
  106. package/src/services/queue-webhook-service.ts +1 -1
  107. package/src/services/scoped-secret-service.ts +4 -3
  108. package/src/services/secret-service.ts +8 -3
  109. package/src/services/typed-secret-service.ts +11 -7
  110. package/src/services/webhook-service.ts +4 -1
  111. package/src/testing/service-tests.ts +6 -6
  112. package/src/types/core.types.ts +25 -4
  113. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  114. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  115. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  116. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  117. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  118. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  119. package/src/wirings/ai-agent/index.ts +2 -0
  120. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  121. package/src/wirings/ai-agent/voice-input.ts +48 -9
  122. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  123. package/src/wirings/ai-agent/voice-output.ts +28 -1
  124. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  125. package/src/wirings/cli/channel/index.ts +4 -0
  126. package/src/wirings/persona/define-personas.ts +4 -0
  127. package/src/wirings/persona/persona.types.ts +11 -0
  128. package/src/wirings/queue/queue-identity.test.ts +2 -1
  129. package/src/wirings/queue/queue-identity.ts +4 -1
  130. package/src/wirings/queue/queue.types.ts +6 -1
  131. package/src/wirings/queue/signed-queue-service.ts +2 -1
  132. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  133. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  134. package/src/wirings/virtual-user/index.ts +12 -0
  135. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  136. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  137. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  138. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  139. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  140. package/src/wirings/workflow/pikku-workflow-service.ts +67 -0
  141. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  142. package/src/wirings/workflow/scenario-prose.ts +1 -1
  143. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  144. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  145. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  146. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  147. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
  148. package/src/wirings/workflow/workflow.types.ts +7 -0
  149. package/tsconfig.tsbuildinfo +1 -1
  150. package/tsconfig.type-tests.json +12 -0
@@ -0,0 +1,176 @@
1
+ import { createReadStream } from 'fs';
2
+ import { mkdir, stat, writeFile } from 'fs/promises';
3
+ import { normalize, resolve } from 'path';
4
+ import { Readable } from 'stream';
5
+ import { signedContentPath } from './local-content.js';
6
+ const matchesPrefix = (pathname, prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
7
+ const contentKey = (pathname, prefix) => pathname.slice(prefix.length).replace(/^\/+/, '');
8
+ /**
9
+ * Resolve a key against the content root, or `null` if it escapes. `normalize`
10
+ * first so `..` segments are collapsed before the prefix check, and the
11
+ * comparison carries a trailing separator so a sibling directory whose name
12
+ * merely starts with the root's cannot pass as being inside it.
13
+ */
14
+ const toTargetPath = (basePath, key) => {
15
+ const normalizedBasePath = resolve(basePath);
16
+ const targetPath = resolve(normalizedBasePath, normalize(key));
17
+ return targetPath.startsWith(`${normalizedBasePath}/`) ? targetPath : null;
18
+ };
19
+ const parseSizeLimit = (sizeLimit) => {
20
+ const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim());
21
+ if (!match) {
22
+ throw new Error(`Invalid size limit: ${sizeLimit}`);
23
+ }
24
+ const value = Number(match[1]);
25
+ const unit = (match[2] ?? 'b').toLowerCase();
26
+ const multiplier = unit === 'gb'
27
+ ? 1024 * 1024 * 1024
28
+ : unit === 'mb'
29
+ ? 1024 * 1024
30
+ : unit === 'kb'
31
+ ? 1024
32
+ : 1;
33
+ return value * multiplier;
34
+ };
35
+ const text = (status, body) => new Response(body, {
36
+ status,
37
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
38
+ });
39
+ export const createLocalContentRequestHandler = ({ content, logger, getJWT, }) => {
40
+ // Logged at most once. An unverifiable request is attacker-triggerable, so
41
+ // this reports a startup misconfiguration rather than per-request news.
42
+ let loggedMissingJWT = false;
43
+ const validateSignedAssetRequest = async (requestUrl) => {
44
+ const signedAtValue = requestUrl.searchParams.get('signedAt');
45
+ const expiresAtValue = requestUrl.searchParams.get('expiresAt');
46
+ const notBeforeValue = requestUrl.searchParams.get('notBefore');
47
+ const signature = requestUrl.searchParams.get('signature');
48
+ if (!signedAtValue || !expiresAtValue) {
49
+ return { ok: false, status: 403, body: 'Signed URL required' };
50
+ }
51
+ const signedAt = Number(signedAtValue);
52
+ const expiresAt = Number(expiresAtValue);
53
+ const notBefore = notBeforeValue == null ? undefined : Number(notBeforeValue);
54
+ if (!Number.isFinite(signedAt) ||
55
+ !Number.isFinite(expiresAt) ||
56
+ (notBefore != null && !Number.isFinite(notBefore))) {
57
+ return { ok: false, status: 403, body: 'Invalid signed URL' };
58
+ }
59
+ const now = Date.now();
60
+ if (now > expiresAt || (notBefore != null && now < notBefore)) {
61
+ return { ok: false, status: 403, body: 'Signed URL expired' };
62
+ }
63
+ const jwt = getJWT();
64
+ if (!jwt) {
65
+ if (!loggedMissingJWT) {
66
+ loggedMissingJWT = true;
67
+ logger.error('pikku: refusing signed asset reads — no JWTService is available to verify them. Pass `contentSigningJWT` (the same service LocalContent signs with) or expose it as `singletonServices.jwt`.');
68
+ }
69
+ return { ok: false, status: 403, body: 'Invalid signed URL' };
70
+ }
71
+ if (!signature) {
72
+ return { ok: false, status: 403, body: 'Signed URL signature required' };
73
+ }
74
+ try {
75
+ const payload = await jwt.decode(signature);
76
+ // Every claim is compared, the path included: without it a signature
77
+ // minted for one asset would read any other.
78
+ if (payload.signedAt !== signedAt ||
79
+ payload.expiresAt !== expiresAt ||
80
+ payload.notBefore !== notBefore ||
81
+ payload.path !== signedContentPath(requestUrl.pathname)) {
82
+ return { ok: false, status: 403, body: 'Invalid signed URL' };
83
+ }
84
+ }
85
+ catch {
86
+ return { ok: false, status: 403, body: 'Invalid signed URL' };
87
+ }
88
+ return { ok: true };
89
+ };
90
+ const handleUpload = async (request, pathname) => {
91
+ const key = contentKey(pathname, content.uploadUrlPrefix);
92
+ const targetPath = toTargetPath(content.localFileUploadPath, key);
93
+ if (!targetPath) {
94
+ return text(400, 'Invalid path');
95
+ }
96
+ const maxBytes = parseSizeLimit(content.sizeLimit ?? '1mb');
97
+ // Counted as it arrives and abandoned the moment it goes over, so an
98
+ // oversized upload costs the limit rather than its own size — `arrayBuffer()`
99
+ // would have to hold all of it first, which hands an unauthenticated caller
100
+ // a way to spend the server's memory. Mirrors node-http-server's
101
+ // `readRequestBody`, which aborts the same way.
102
+ const chunks = [];
103
+ let bytesRead = 0;
104
+ const reader = request.body?.getReader();
105
+ if (reader) {
106
+ try {
107
+ for (;;) {
108
+ const { done, value } = await reader.read();
109
+ if (done)
110
+ break;
111
+ bytesRead += value.byteLength;
112
+ if (bytesRead > maxBytes) {
113
+ await reader.cancel();
114
+ return text(413, 'Content too large');
115
+ }
116
+ chunks.push(Buffer.from(value));
117
+ }
118
+ }
119
+ finally {
120
+ reader.releaseLock();
121
+ }
122
+ }
123
+ await mkdir(resolve(targetPath, '..'), { recursive: true });
124
+ await writeFile(targetPath, Buffer.concat(chunks));
125
+ return new Response(null, { status: 200 });
126
+ };
127
+ const handleAsset = async (request, requestUrl, pathname) => {
128
+ const key = contentKey(pathname, content.assetUrlPrefix);
129
+ const targetPath = toTargetPath(content.localFileUploadPath, key);
130
+ if (!targetPath) {
131
+ return text(400, 'Invalid path');
132
+ }
133
+ const signed = await validateSignedAssetRequest(requestUrl);
134
+ if (!signed.ok) {
135
+ return text(signed.status, signed.body);
136
+ }
137
+ try {
138
+ const file = await stat(targetPath);
139
+ if (!file.isFile()) {
140
+ return new Response(null, { status: 404 });
141
+ }
142
+ const headers = {
143
+ 'content-length': String(file.size),
144
+ 'content-type': 'application/octet-stream',
145
+ };
146
+ if (request.method === 'HEAD') {
147
+ return new Response(null, { status: 200, headers });
148
+ }
149
+ // Streamed rather than buffered: assets are user uploads, and their size
150
+ // is bounded by `sizeLimit` at write time, not by anything here.
151
+ return new Response(Readable.toWeb(createReadStream(targetPath)), { status: 200, headers });
152
+ }
153
+ catch {
154
+ return new Response(null, { status: 404 });
155
+ }
156
+ };
157
+ return async (request) => {
158
+ let requestUrl;
159
+ try {
160
+ requestUrl = new URL(request.url);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ const pathname = decodeURIComponent(requestUrl.pathname);
166
+ if (request.method === 'PUT' &&
167
+ matchesPrefix(pathname, content.uploadUrlPrefix)) {
168
+ return handleUpload(request, pathname);
169
+ }
170
+ if ((request.method === 'GET' || request.method === 'HEAD') &&
171
+ matchesPrefix(pathname, content.assetUrlPrefix)) {
172
+ return handleAsset(request, requestUrl, pathname);
173
+ }
174
+ return null;
175
+ };
176
+ };
@@ -1,13 +1,14 @@
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
  import type { VariablesService } from './variables-service.js';
3
4
  export declare class LocalSecretService implements SecretService {
4
5
  private variables;
5
6
  private localSecrets;
6
7
  private parseSecret;
7
8
  constructor(variables?: VariablesService);
8
- getSecret<T = string>(key: string): Promise<T>;
9
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>;
9
10
  setSecret(key: string, value: unknown): Promise<void>;
10
11
  hasSecret(key: string): Promise<boolean>;
11
12
  deleteSecret(key: string): Promise<void>;
12
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
13
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
13
14
  }
@@ -1,3 +1,4 @@
1
+ import { createSecretValue, isSecretValue, } from '../secret-value.js';
1
2
  import { LocalVariablesService } from './local-variables.js';
2
3
  export class LocalSecretService {
3
4
  variables;
@@ -16,16 +17,19 @@ export class LocalSecretService {
16
17
  async getSecret(key) {
17
18
  const localValue = this.localSecrets.get(key);
18
19
  if (localValue) {
19
- return this.parseSecret(localValue);
20
+ return createSecretValue(this.parseSecret(localValue));
20
21
  }
21
22
  const value = await this.variables.get(key);
22
23
  if (value) {
23
- return this.parseSecret(value);
24
+ return createSecretValue(this.parseSecret(value));
24
25
  }
25
26
  throw new Error('Requested secret not found');
26
27
  }
27
28
  async setSecret(key, value) {
28
- this.localSecrets.set(key, typeof value === 'string' ? value : JSON.stringify(value));
29
+ // Storing the wrapper would serialize it to '[secret]', so unwrap first
30
+ // writing a secret back to the vault is exactly what this method is for.
31
+ const raw = isSecretValue(value) ? value.reveal() : value;
32
+ this.localSecrets.set(key, typeof raw === 'string' ? raw : JSON.stringify(raw));
29
33
  }
30
34
  async hasSecret(key) {
31
35
  if (this.localSecrets.has(key)) {
@@ -1,3 +1,4 @@
1
+ import type { Safe } from '../secret-value.js';
1
2
  export declare enum LogLevel {
2
3
  'trace' = 0,
3
4
  'debug' = 1,
@@ -6,12 +7,28 @@ export declare enum LogLevel {
6
7
  'error' = 4,
7
8
  'critical' = 5
8
9
  }
10
+ /**
11
+ * A log line is the easiest place to leak a vault secret, so every parameter is
12
+ * `Safe<>`-guarded: a `SecretValue` anywhere in the message or the metadata,
13
+ * however deeply nested, collapses to `never` and fails the build. Reveal it
14
+ * first if you genuinely mean to log it.
15
+ */
9
16
  export interface Logger {
10
- info(messageOrObj: string | Record<string, any>, ...meta: any[]): void;
11
- warn(messageOrObj: string | Record<string, any>, ...meta: any[]): void;
12
- error(messageOrObj: string | Record<string, any> | Error, ...meta: any[]): void;
13
- debug(message: string, ...meta: any[]): void;
14
- trace?(message: string, ...meta: any[]): void;
17
+ info<M extends string | Record<string, any>, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
18
+ [K in keyof A]: Safe<A[K]>;
19
+ }): void;
20
+ warn<M extends string | Record<string, any>, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
21
+ [K in keyof A]: Safe<A[K]>;
22
+ }): void;
23
+ error<M extends string | Record<string, any> | Error, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
24
+ [K in keyof A]: Safe<A[K]>;
25
+ }): void;
26
+ debug<A extends unknown[]>(message: string, ...meta: {
27
+ [K in keyof A]: Safe<A[K]>;
28
+ }): void;
29
+ trace?<A extends unknown[]>(message: string, ...meta: {
30
+ [K in keyof A]: Safe<A[K]>;
31
+ }): void;
15
32
  setLevel(level: LogLevel): void;
16
33
  /** A logger carrying a traceId on every entry, taken per-request to correlate calls. */
17
34
  scope?(traceId: string): Logger;
@@ -32,7 +32,7 @@ export class QueueWebhookService extends WebhookService {
32
32
  let secret = input.secret;
33
33
  if (secret === undefined && webhookConfig?.secret) {
34
34
  // Naive read: caching is the secret service's concern, not ours.
35
- secret = await services.secrets.getSecret(webhookConfig.secret);
35
+ secret = (await services.secrets.getSecret(webhookConfig.secret)).reveal();
36
36
  if (!secret) {
37
37
  services.logger.error(`Webhook signing secret '${webhookConfig.secret}' (config.webhook.secret) resolved to nothing — outgoing webhooks will be sent UNSIGNED.`);
38
38
  }
@@ -1,12 +1,13 @@
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
  export declare class ScopedSecretService implements SecretService {
3
4
  private secrets;
4
5
  private allowedKeys;
5
6
  constructor(secrets: SecretService, allowedKeys: Set<string>);
6
7
  private assertAllowed;
7
- getSecret<T = string>(key: string): Promise<T>;
8
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>;
8
9
  hasSecret(key: string): Promise<boolean>;
9
10
  setSecret(_key: string, _value: unknown): Promise<void>;
10
11
  deleteSecret(_key: string): Promise<void>;
11
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
12
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
12
13
  }
@@ -1,6 +1,11 @@
1
+ import type { SecretValue } from '../secret-value.js';
2
+ /** A record of secrets, each still wrapped. */
3
+ export type SecretValues<T> = {
4
+ [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>;
@@ -10,5 +15,5 @@ export interface SecretService {
10
15
  * must handle keys absent at runtime. Pass a shape as `T` to avoid casting,
11
16
  * e.g. `getSecrets<{ FOO: string; BAR: { id: string } }>(['FOO', 'BAR'])`.
12
17
  */
13
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
18
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
14
19
  }
@@ -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
  export interface CredentialStatus {
3
4
  secretId: string;
4
5
  name: string;
@@ -20,12 +21,12 @@ export declare class TypedSecretService<TMap = Record<string, unknown>> implemen
20
21
  private credentialsMeta;
21
22
  private cache;
22
23
  constructor(secrets: SecretService, credentialsMeta: Record<string, CredentialMeta>);
23
- getSecret<K extends keyof TMap & string>(key: K): Promise<TMap[K]>;
24
- getSecret<T = string>(key: string): Promise<T>;
24
+ getSecret<K extends keyof TMap & string>(key: K): Promise<SecretValue<TMap[K]>>;
25
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>;
25
26
  hasSecret(key: string): Promise<boolean>;
26
27
  setSecret<K extends string>(key: K, value: K extends keyof TMap ? TMap[K] : unknown): Promise<void>;
27
28
  deleteSecret(key: string): Promise<void>;
28
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
29
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
29
30
  getAllStatus(): Promise<CredentialStatus[]>;
30
31
  getMissing(): Promise<CredentialStatus[]>;
31
32
  }
@@ -1,3 +1,4 @@
1
+ import type { Safe } from '../secret-value.js';
1
2
  export interface SendWebhookInput {
2
3
  url: string;
3
4
  /** Sent as the `X-Pikku-Event` header. */
@@ -76,7 +77,7 @@ export declare const PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME = "pikku-outgoing-webhook
76
77
  export declare const DEFAULT_WEBHOOK_SIGNATURE_HEADER = "X-Pikku-Signature";
77
78
  export declare const DEFAULT_WEBHOOK_RETRIES = 3;
78
79
  export declare abstract class WebhookService {
79
- abstract send(input: SendWebhookInput): Promise<SendWebhookResult>;
80
+ abstract send<T extends SendWebhookInput>(input: Safe<T>): Promise<SendWebhookResult>;
80
81
  /** Produces the header value, `sha256=<hex>`, not the bare digest. */
81
82
  protected sign(secret: string, body: string): string;
82
83
  /** Public because receivers verify with it; they share the signing scheme. */
@@ -542,16 +542,16 @@ export function defineServiceTests(config) {
542
542
  endpoint: 'https://api.example.com',
543
543
  });
544
544
  const result = await service.getSecret('api-key');
545
- assert.deepEqual(result, {
545
+ assert.deepEqual(result.reveal(), {
546
546
  token: 'sk-123',
547
547
  endpoint: 'https://api.example.com',
548
548
  });
549
549
  });
550
- test('getSecret returns raw string', async () => {
550
+ test('getSecret wraps the raw string rather than returning it', async () => {
551
551
  const service = await factory({ key: kek });
552
552
  await service.setSecret('string-secret', 'plain-value');
553
553
  const result = await service.getSecret('string-secret');
554
- assert.strictEqual(result, 'plain-value');
554
+ assert.strictEqual(result.reveal(), 'plain-value');
555
555
  });
556
556
  test('hasSecret returns true/false', async () => {
557
557
  const service = await factory({ key: kek });
@@ -569,7 +569,7 @@ export function defineServiceTests(config) {
569
569
  await service.setSecret('upsert-key', { v: 1 });
570
570
  await service.setSecret('upsert-key', { v: 2 });
571
571
  const result = await service.getSecret('upsert-key');
572
- assert.deepEqual(result, { v: 2 });
572
+ assert.deepEqual(result.reveal(), { v: 2 });
573
573
  });
574
574
  test('deleteSecret removes the key', async () => {
575
575
  const service = await factory({ key: kek });
@@ -588,7 +588,7 @@ export function defineServiceTests(config) {
588
588
  previousKey: kek,
589
589
  });
590
590
  const before = await rotatedService.getSecret('rotate-test');
591
- assert.deepEqual(before, { important: 'data' });
591
+ assert.deepEqual(before.reveal(), { important: 'data' });
592
592
  assert.ok(rotatedService.rotateKEK);
593
593
  const count = await rotatedService.rotateKEK();
594
594
  assert.ok(count > 0);
@@ -597,7 +597,7 @@ export function defineServiceTests(config) {
597
597
  keyVersion: 2,
598
598
  });
599
599
  const after = await newOnlyService.getSecret('rotate-test');
600
- assert.deepEqual(after, { important: 'data' });
600
+ assert.deepEqual(after.reveal(), { important: 'data' });
601
601
  });
602
602
  test('rotateKEK throws without previousKey', async () => {
603
603
  const service = await factory({ key: kek });
@@ -25,6 +25,7 @@ import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.j
25
25
  import type { AIEmbeddingService } from '../services/ai-embedding-service.js';
26
26
  import type { AIRunStateService } from '../services/ai-run-state-service.js';
27
27
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
28
+ import type { VirtualUserRunStore } from '../wirings/virtual-user/virtual-user-run-store.js';
28
29
  import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.js';
29
30
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
30
31
  import type { CredentialService } from '../services/credential-service.js';
@@ -79,11 +80,12 @@ export type FunctionRuntimeMeta = {
79
80
  */
80
81
  auth?: boolean;
81
82
  /**
82
- * The author's declaration that this function authorizes callers in its own
83
- * body. Carries no runtime effect — it records a gate codegen cannot see, so
84
- * an audit is not left to guess whether a sessionless function is open.
83
+ * The author's declaration that this function's permission check lives in its
84
+ * own body. Carries no runtime effect — it records a gate codegen cannot see,
85
+ * so an audit is not left to guess whether a sessionless function is open.
86
+ * Refused unless `allow.permissionsInBody` is set in `pikku.config.json`.
85
87
  */
86
- selfAuthenticated?: boolean;
88
+ permissionsInBody?: boolean;
87
89
  remote?: boolean;
88
90
  /**
89
91
  * A step RPC: invoked by name only from a scenario run and refused
@@ -127,6 +129,19 @@ export type FunctionRuntimeMeta = {
127
129
  scenarioStepAddon?: string;
128
130
  /** Scenario steps only: the prose a reporter renders, with `{placeholders}` filled from the step's recorded input. */
129
131
  scenarioStepTemplate?: string;
132
+ /**
133
+ * The function's `audit` config, resolved — `audit: true` reads as
134
+ * `{ durability: 'best-effort' }`. Absent means the function records nothing:
135
+ * `auditLog.write()` from an unmarked function is dropped with a warning, so
136
+ * this is the only place a reader can see which functions have a trail at all
137
+ * without running them.
138
+ *
139
+ * Informational. The runner resolves audit from the live function config, not
140
+ * from here, so meta and runtime cannot disagree about whether audit is on.
141
+ */
142
+ audit?: {
143
+ durability: AuditDurability;
144
+ };
130
145
  /** Keeps the full `SecretService`. Set by the inspector, read by the runner. */
131
146
  secretBroker?: boolean;
132
147
  version?: number;
@@ -255,6 +270,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
255
270
  */
256
271
  webhookService?: WebhookService;
257
272
  metaService?: MetaService;
273
+ /**
274
+ * Where virtual-user runs are recorded. A run is dispatched and answered for
275
+ * later, so this store is the only trace it leaves — see
276
+ * {@link VirtualUserRunStore}.
277
+ */
278
+ virtualUserRunStore?: VirtualUserRunStore;
258
279
  /** V8 precise-coverage collector (`pikku dev --coverage` only) */
259
280
  coverageService?: CoverageService;
260
281
  audit?: AuditService;
@@ -305,7 +305,11 @@ export function wrapChannelWithAGUI(inner, options) {
305
305
  send({
306
306
  type: 'CUSTOM',
307
307
  name: 'pikku:audio-delta',
308
- value: { data: event.data, format: event.format },
308
+ value: {
309
+ data: event.data,
310
+ format: event.format,
311
+ ...(event.text === undefined ? {} : { text: event.text }),
312
+ },
309
313
  });
310
314
  break;
311
315
  }
@@ -313,6 +317,14 @@ export function wrapChannelWithAGUI(inner, options) {
313
317
  send({ type: 'CUSTOM', name: 'pikku:audio-done', value: {} });
314
318
  break;
315
319
  }
320
+ case 'transcript': {
321
+ send({
322
+ type: 'CUSTOM',
323
+ name: 'pikku:transcript',
324
+ value: { text: event.text },
325
+ });
326
+ break;
327
+ }
316
328
  }
317
329
  },
318
330
  };
@@ -660,7 +660,13 @@ export async function prepareAgentRun(agentName, input, params, agentSessionMap,
660
660
  const contextMessages = await loadContextMessages(memoryConfig, storage, input, workingMemoryJsonSchema);
661
661
  const userContent = input.attachments?.length
662
662
  ? [
663
- { type: 'text', text: input.message },
663
+ // Omitted when there is nothing to say. An attachment on its own is a
664
+ // real turn — a spoken one carries audio and no text at all — and an
665
+ // empty text part alongside it is a part providers are entitled to
666
+ // reject, for a caller who never wrote one.
667
+ ...(input.message
668
+ ? [{ type: 'text', text: input.message }]
669
+ : []),
664
670
  ...input.attachments.map((a) => ({
665
671
  type: a.type,
666
672
  data: a.data,
@@ -73,6 +73,8 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
73
73
  }),
74
74
  ...(agent.aiMiddleware ?? []),
75
75
  ];
76
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
77
+ const sharedNotes = {};
76
78
  let modifiedMessages = runnerParams.messages;
77
79
  let modifiedInstructions = runnerParams.instructions;
78
80
  for (const mw of aiMiddlewares) {
@@ -80,6 +82,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
80
82
  const result = await mw.modifyInput(singletonServices, {
81
83
  messages: modifiedMessages,
82
84
  instructions: modifiedInstructions,
85
+ shared: sharedNotes,
83
86
  });
84
87
  modifiedMessages = result.messages;
85
88
  modifiedInstructions = result.instructions;
@@ -87,6 +90,12 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
87
90
  }
88
91
  runnerParams.messages = modifiedMessages;
89
92
  runnerParams.instructions = modifiedInstructions;
93
+ // History records what the model was asked, which for a spoken turn is the
94
+ // transcript rather than the base64 audio that arrived — see the same note on
95
+ // the streaming path. Identity-checked, because a middleware may legitimately
96
+ // replace the message list with something unrelated to this turn.
97
+ const lastModified = modifiedMessages[modifiedMessages.length - 1];
98
+ const persistedUserMessage = lastModified?.id === userMessage.id ? lastModified : userMessage;
90
99
  const runId = await aiRunState.createRun({
91
100
  agentName,
92
101
  threadId,
@@ -181,7 +190,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
181
190
  args: a.args,
182
191
  });
183
192
  const completedStepsForSave = accumulatedSteps.slice(0, -1);
184
- await saveMessages(storage, threadId, input.resourceId, memoryConfig, userMessage, { text: '', steps: completedStepsForSave });
193
+ await saveMessages(storage, threadId, input.resourceId, memoryConfig, persistedUserMessage, { text: '', steps: completedStepsForSave });
185
194
  if (storage) {
186
195
  await storage.saveMessages(threadId, [
187
196
  {
@@ -242,7 +251,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
242
251
  outputMessages = modResult.messages;
243
252
  }
244
253
  }
245
- await saveMessages(storage, threadId, input.resourceId, memoryConfig, userMessage, {
254
+ await saveMessages(storage, threadId, input.resourceId, memoryConfig, persistedUserMessage, {
246
255
  ...result,
247
256
  text: outputText,
248
257
  uiSpec: structuredOutput.uiSpec,
@@ -416,6 +425,8 @@ async function continueAfterToolResultSync(run, agent, packageName, resolvedName
416
425
  }),
417
426
  ...(agent.aiMiddleware ?? []),
418
427
  ];
428
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
429
+ const sharedNotes = {};
419
430
  let modifiedMessages = trimmedMessages;
420
431
  let modifiedInstructions = instructions;
421
432
  for (const mw of aiMiddlewares) {
@@ -423,6 +434,7 @@ async function continueAfterToolResultSync(run, agent, packageName, resolvedName
423
434
  const result = await mw.modifyInput(singletonServices, {
424
435
  messages: modifiedMessages,
425
436
  instructions: modifiedInstructions,
437
+ shared: sharedNotes,
426
438
  });
427
439
  modifiedMessages = result.messages;
428
440
  modifiedInstructions = result.instructions;
@@ -1,6 +1,7 @@
1
1
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
2
2
  import { AIProviderNotConfiguredError } from '../../errors/errors.js';
3
3
  import { randomUUID } from './ai-agent-utils.js';
4
+ import { SPOKEN_TRANSCRIPT } from './voice-input.js';
4
5
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
5
6
  import { resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
6
7
  import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertResourcePrincipalOwner, assertAgentAuthorized, ToolApprovalRequired, ToolCredentialRequired, APPROVAL_REQUIRED, CREDENTIAL_REQUIRED, } from './ai-agent-prepare.js';
@@ -411,6 +412,8 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
411
412
  }),
412
413
  ...(agent.aiMiddleware ?? []),
413
414
  ];
415
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
416
+ const sharedNotes = {};
414
417
  let modifiedMessages = runnerParams.messages;
415
418
  let modifiedInstructions = runnerParams.instructions;
416
419
  for (const mw of aiMiddlewares) {
@@ -418,6 +421,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
418
421
  const result = await mw.modifyInput(singletonServices, {
419
422
  messages: modifiedMessages,
420
423
  instructions: modifiedInstructions,
424
+ shared: sharedNotes,
421
425
  });
422
426
  modifiedMessages = result.messages;
423
427
  modifiedInstructions = result.instructions;
@@ -425,6 +429,23 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
425
429
  }
426
430
  runnerParams.messages = modifiedMessages;
427
431
  runnerParams.instructions = modifiedInstructions;
432
+ // Sent on the raw channel, ahead of the run. A voice client sent audio and so
433
+ // has no idea what it said; until this arrives its own message is a blank
434
+ // bubble. Ahead of the run rather than alongside it because the answer starts
435
+ // streaming within a few hundred milliseconds, and a question that appears
436
+ // after its answer reads as the wrong question.
437
+ const transcript = sharedNotes[SPOKEN_TRANSCRIPT];
438
+ if (typeof transcript === 'string') {
439
+ channel.send({ type: 'transcript', text: transcript });
440
+ }
441
+ // What goes into thread history is what the model was actually asked. For a
442
+ // spoken turn that is not what arrived over the wire: the wire carried a
443
+ // base64 audio blob, and persisting it would write megabytes of unreadable
444
+ // data into the history while losing the only readable record of the turn.
445
+ // Identity-checked rather than assumed — a middleware is free to rewrite the
446
+ // message list into something with no relation to the turn.
447
+ const lastModified = modifiedMessages[modifiedMessages.length - 1];
448
+ const persistedUserMessage = lastModified?.id === userMessage.id ? lastModified : userMessage;
428
449
  const runId = await aiRunState.createRun({
429
450
  agentName,
430
451
  threadId,
@@ -442,7 +463,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
442
463
  runnerParams.abortSignal = interruptHandle.signal;
443
464
  runnerParams.tools = trackToolExecution(runnerParams.tools, interruptHandle);
444
465
  if (storage) {
445
- await storage.saveMessages(threadId, [userMessage]);
466
+ await storage.saveMessages(threadId, [persistedUserMessage]);
446
467
  }
447
468
  const streamMiddleware = aiMiddlewares
448
469
  .filter((mw) => mw.modifyOutputStream)
@@ -455,6 +476,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
455
476
  event,
456
477
  allEvents,
457
478
  state,
479
+ shared: sharedNotes,
458
480
  // Sends downstream directly, so a hook can hand back the fast event
459
481
  // now and push the slow one when it is ready.
460
482
  emit: next,
@@ -841,6 +863,8 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
841
863
  }),
842
864
  ...(agent.aiMiddleware ?? []),
843
865
  ];
866
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
867
+ const sharedNotes = {};
844
868
  let modifiedMessages = trimmedMessages;
845
869
  let modifiedInstructions = instructions;
846
870
  for (const mw of aiMiddlewares) {
@@ -848,6 +872,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
848
872
  const result = await mw.modifyInput(singletonServices, {
849
873
  messages: modifiedMessages,
850
874
  instructions: modifiedInstructions,
875
+ shared: sharedNotes,
851
876
  });
852
877
  modifiedMessages = result.messages;
853
878
  modifiedInstructions = result.instructions;
@@ -869,6 +894,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
869
894
  event,
870
895
  allEvents,
871
896
  state,
897
+ shared: sharedNotes,
872
898
  // Sends downstream directly, so a hook can hand back the fast event
873
899
  // now and push the slow one when it is ready.
874
900
  emit: next,