@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.
- package/CHANGELOG.md +378 -0
- package/dist/column-form.d.ts +32 -0
- package/dist/column-form.js +42 -0
- package/dist/crypto-utils.d.ts +15 -4
- package/dist/crypto-utils.js +18 -2
- package/dist/data-classification.d.ts +44 -0
- package/dist/function/functions.types.d.ts +23 -10
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/middleware/auth-bearer.js +2 -1
- package/dist/middleware/remote-auth.js +1 -1
- package/dist/remote.js +1 -1
- package/dist/secret-value.d.ts +56 -0
- package/dist/secret-value.js +46 -0
- package/dist/services/audit-service.d.ts +74 -4
- package/dist/services/audit-service.js +7 -5
- package/dist/services/credential-wire-service.d.ts +5 -0
- package/dist/services/credential-wire-service.js +9 -1
- package/dist/services/email-service.d.ts +2 -1
- package/dist/services/index.d.ts +3 -3
- package/dist/services/index.js +1 -1
- package/dist/services/local-content-request-handler.d.ts +29 -0
- package/dist/services/local-content-request-handler.js +176 -0
- package/dist/services/local-secrets.d.ts +4 -3
- package/dist/services/local-secrets.js +7 -3
- package/dist/services/logger.d.ts +22 -5
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-secret-service.d.ts +4 -3
- package/dist/services/secret-service.d.ts +8 -3
- package/dist/services/typed-secret-service.d.ts +5 -4
- package/dist/services/webhook-service.d.ts +2 -1
- package/dist/testing/service-tests.js +6 -6
- package/dist/types/core.types.d.ts +25 -4
- package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
- package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
- package/dist/wirings/ai-agent/voice-input.js +44 -9
- package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
- package/dist/wirings/ai-agent/voice-output.js +10 -1
- package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
- package/dist/wirings/cli/channel/index.d.ts +1 -0
- package/dist/wirings/persona/define-personas.d.ts +4 -0
- package/dist/wirings/persona/define-personas.js +4 -0
- package/dist/wirings/persona/persona.types.d.ts +11 -0
- package/dist/wirings/queue/queue-identity.js +2 -1
- package/dist/wirings/queue/queue.types.d.ts +2 -1
- package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.js +6 -2
- package/dist/wirings/virtual-user/index.d.ts +3 -0
- package/dist/wirings/virtual-user/index.js +2 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
- package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
- package/dist/wirings/workflow/scenario-prose.js +1 -1
- package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
- package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
- package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
- package/knowledge/decisions/internals/index.md +4 -0
- package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
- package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
- package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
- package/package.json +3 -2
- package/src/column-form.test.ts +97 -0
- package/src/column-form.ts +58 -0
- package/src/crypto-utils.ts +25 -6
- package/src/data-classification.ts +44 -0
- package/src/function/functions.types.ts +47 -10
- package/src/function/index.ts +1 -0
- package/src/index.ts +24 -2
- package/src/middleware/auth-bearer.test.ts +3 -2
- package/src/middleware/auth-bearer.ts +2 -1
- package/src/middleware/remote-auth.test.ts +2 -1
- package/src/middleware/remote-auth.ts +1 -1
- package/src/remote.test.ts +2 -1
- package/src/remote.ts +1 -1
- package/src/secret-value.test.ts +204 -0
- package/src/secret-value.ts +111 -0
- package/src/services/audit-service.ts +87 -9
- package/src/services/credential-wire-service.ts +9 -1
- package/src/services/email-service.ts +3 -1
- package/src/services/index.ts +3 -3
- package/src/services/local-content-request-handler.test.ts +202 -0
- package/src/services/local-content-request-handler.ts +267 -0
- package/src/services/local-secrets.test.ts +20 -5
- package/src/services/local-secrets.ts +15 -7
- package/src/services/logger.ts +27 -7
- package/src/services/queue-webhook-service.test.ts +2 -1
- package/src/services/queue-webhook-service.ts +1 -1
- package/src/services/scoped-secret-service.ts +4 -3
- package/src/services/secret-service.ts +8 -3
- package/src/services/typed-secret-service.ts +11 -7
- package/src/services/webhook-service.ts +4 -1
- package/src/testing/service-tests.ts +6 -6
- package/src/types/core.types.ts +25 -4
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
- package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
- package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
- package/src/wirings/ai-agent/index.ts +2 -0
- package/src/wirings/ai-agent/voice-input.test.ts +65 -0
- package/src/wirings/ai-agent/voice-input.ts +48 -9
- package/src/wirings/ai-agent/voice-output.test.ts +91 -1
- package/src/wirings/ai-agent/voice-output.ts +28 -1
- package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
- package/src/wirings/cli/channel/index.ts +4 -0
- package/src/wirings/persona/define-personas.ts +4 -0
- package/src/wirings/persona/persona.types.ts +11 -0
- package/src/wirings/queue/queue-identity.test.ts +2 -1
- package/src/wirings/queue/queue-identity.ts +4 -1
- package/src/wirings/queue/queue.types.ts +6 -1
- package/src/wirings/queue/signed-queue-service.ts +2 -1
- package/src/wirings/rpc/remote-addon-auth.ts +8 -3
- package/src/wirings/rpc/rpc-runner.test.ts +6 -4
- package/src/wirings/virtual-user/index.ts +12 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
- package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
- package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
- package/src/wirings/workflow/scenario-prose.test.ts +5 -7
- package/src/wirings/workflow/scenario-prose.ts +1 -1
- package/src/wirings/workflow/scenario-service.test.ts +0 -1
- package/src/wirings/workflow/scenario-step.test.ts +4 -5
- package/src/wirings/workflow/scenario-step.types.ts +13 -7
- package/src/wirings/workflow/scenario-surface.test.ts +6 -5
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
- 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
|
|
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
|
}
|
package/src/services/logger.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
17
|
-
...meta:
|
|
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
|
|
34
|
+
debug<A extends unknown[]>(
|
|
35
|
+
message: string,
|
|
36
|
+
...meta: { [K in keyof A]: Safe<A[K]> }
|
|
37
|
+
): void
|
|
21
38
|
|
|
22
|
-
trace
|
|
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 {
|
|
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 {
|
|
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>(
|
|
29
|
-
|
|
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 &
|
|
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
|
|
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
|
|
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 () => {
|
package/src/types/core.types.ts
CHANGED
|
@@ -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
|
|
129
|
-
* body. Carries no runtime effect — it records a gate codegen cannot see,
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, [
|
|
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,
|