@pikku/core 0.12.98 → 0.12.99
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 +51 -0
- package/dist/services/http-personas.d.ts +10 -4
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/persona-actor-secret.d.ts +38 -0
- package/dist/services/persona-actor-secret.js +39 -0
- package/dist/services/persona-sign-in.d.ts +11 -1
- package/dist/services/persona-sign-in.js +10 -1
- package/dist/services/typed-secret-service.js +4 -1
- package/dist/wirings/agent-scorer/agent-scorer.d.ts +14 -0
- package/dist/wirings/gateway/gateway.types.d.ts +13 -0
- package/dist/wirings/persona/index.d.ts +2 -1
- package/dist/wirings/persona/index.js +1 -0
- package/dist/wirings/secret/secret.types.d.ts +8 -0
- package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +1 -1
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-but-it-needs-a-trigger.md +65 -0
- package/knowledge/decisions/internals/index.md +1 -1
- package/knowledge/decisions/security/actor-sign-in-only-works-for-actor-flagged-users.md +19 -15
- package/knowledge/decisions/security/an-actor-credential-is-derived-per-persona.md +41 -0
- package/knowledge/decisions/security/index.md +2 -1
- package/package.json +4 -4
- package/src/public-surface.json +12 -0
- package/src/services/http-personas-converse.test.ts +3 -3
- package/src/services/http-personas.test.ts +13 -5
- package/src/services/http-personas.ts +10 -3
- package/src/services/index.ts +8 -0
- package/src/services/persona-actor-secret.test.ts +68 -0
- package/src/services/persona-actor-secret.ts +70 -0
- package/src/services/persona-sign-in.ts +20 -2
- package/src/services/typed-secret-service.test.ts +26 -1
- package/src/services/typed-secret-service.ts +4 -1
- package/src/wirings/agent-scorer/agent-scorer.ts +14 -0
- package/src/wirings/gateway/gateway.types.ts +20 -1
- package/src/wirings/persona/index.ts +9 -0
- package/src/wirings/secret/secret.types.ts +8 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +0 -53
package/src/public-surface.json
CHANGED
|
@@ -289,15 +289,20 @@
|
|
|
289
289
|
"validateAndBuildSystemRoleDefinitionsMeta"
|
|
290
290
|
],
|
|
291
291
|
"./persona": [
|
|
292
|
+
"ACTOR_ROOT_SECRET_MIN_LENGTH",
|
|
293
|
+
"ACTOR_SECRET_INFO",
|
|
294
|
+
"ACTOR_SECRET_NAME",
|
|
292
295
|
"APP_SCOPE_ROOT",
|
|
293
296
|
"ActorSignIn",
|
|
294
297
|
"HttpPersona",
|
|
295
298
|
"IMPERSONATE_USER_ID_HEADER",
|
|
296
299
|
"OperatorSignIn",
|
|
300
|
+
"actorSecretSubject",
|
|
297
301
|
"appScopeId",
|
|
298
302
|
"buildAppScopeDefinition",
|
|
299
303
|
"createHttpPersonas",
|
|
300
304
|
"definePersonas",
|
|
305
|
+
"deriveActorSecret",
|
|
301
306
|
"establishOperatorSession",
|
|
302
307
|
"isRunnablePersona",
|
|
303
308
|
"personaEmail",
|
|
@@ -309,6 +314,7 @@
|
|
|
309
314
|
"roleMismatchMessage",
|
|
310
315
|
"runnablePersonas",
|
|
311
316
|
"validateAndBuildPersonasMeta",
|
|
317
|
+
"verifyActorSecret",
|
|
312
318
|
"verifyPersonaRoles"
|
|
313
319
|
],
|
|
314
320
|
"./secret": ["defineSecret", "validateAndBuildSecretDefinitionsMeta"],
|
|
@@ -364,6 +370,9 @@
|
|
|
364
370
|
"isExpectedError"
|
|
365
371
|
],
|
|
366
372
|
"./services": [
|
|
373
|
+
"ACTOR_ROOT_SECRET_MIN_LENGTH",
|
|
374
|
+
"ACTOR_SECRET_INFO",
|
|
375
|
+
"ACTOR_SECRET_NAME",
|
|
367
376
|
"ConsoleLogger",
|
|
368
377
|
"DEFAULT_WEBHOOK_RETRIES",
|
|
369
378
|
"FileScenarioRunStore",
|
|
@@ -394,10 +403,12 @@
|
|
|
394
403
|
"TypedSecretService",
|
|
395
404
|
"TypedVariablesService",
|
|
396
405
|
"WebhookService",
|
|
406
|
+
"actorSecretSubject",
|
|
397
407
|
"assertSecretAllowedForHost",
|
|
398
408
|
"createInvocationAudit",
|
|
399
409
|
"createMiddlewareSessionWireProps",
|
|
400
410
|
"createStubProxy",
|
|
411
|
+
"deriveActorSecret",
|
|
401
412
|
"getStubTracker",
|
|
402
413
|
"isTestRun",
|
|
403
414
|
"pikkuWebhookWorkerFunc",
|
|
@@ -406,6 +417,7 @@
|
|
|
406
417
|
"scenarioRunSummary",
|
|
407
418
|
"spy",
|
|
408
419
|
"stub",
|
|
420
|
+
"verifyActorSecret",
|
|
409
421
|
"withoutSecrets"
|
|
410
422
|
],
|
|
411
423
|
"./services/local-meta": ["LocalMetaService"],
|
|
@@ -161,7 +161,7 @@ describe('HttpPersona.converse', async () => {
|
|
|
161
161
|
|
|
162
162
|
const actors = createHttpPersonas({
|
|
163
163
|
apiUrl: target.apiUrl,
|
|
164
|
-
secret: 'impersonation-secret',
|
|
164
|
+
secret: 'impersonation-secret-impersonation',
|
|
165
165
|
model: 'test/test-model',
|
|
166
166
|
personas: {
|
|
167
167
|
pm: {
|
|
@@ -204,7 +204,7 @@ describe('HttpPersona.converse', async () => {
|
|
|
204
204
|
|
|
205
205
|
const actors = createHttpPersonas({
|
|
206
206
|
apiUrl: target.apiUrl,
|
|
207
|
-
secret: 'impersonation-secret',
|
|
207
|
+
secret: 'impersonation-secret-impersonation',
|
|
208
208
|
model: 'test/test-model',
|
|
209
209
|
personas: {
|
|
210
210
|
pm: {
|
|
@@ -243,7 +243,7 @@ describe('HttpPersona.converse', async () => {
|
|
|
243
243
|
|
|
244
244
|
const actors = createHttpPersonas({
|
|
245
245
|
apiUrl: target.apiUrl,
|
|
246
|
-
secret: 'impersonation-secret',
|
|
246
|
+
secret: 'impersonation-secret-impersonation',
|
|
247
247
|
model: 'test/test-model',
|
|
248
248
|
personas: {
|
|
249
249
|
pm: {
|
|
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { createServer, type Server } from 'node:http'
|
|
4
4
|
|
|
5
5
|
import { createHttpPersonas } from './http-personas.js'
|
|
6
|
+
import { verifyActorSecret } from './persona-actor-secret.js'
|
|
7
|
+
|
|
8
|
+
/** The root the personas derive from; the target verifies against the same one. */
|
|
9
|
+
const ROOT = 'impersonation-secret-impersonation'
|
|
6
10
|
|
|
7
11
|
// Minimal target app mirroring the Better Auth actor plugin's contract:
|
|
8
12
|
// sign-in endpoint, exposed RPC endpoint, session by cookie.
|
|
@@ -12,12 +16,12 @@ const startTarget = async () => {
|
|
|
12
16
|
const server: Server = createServer((req, res) => {
|
|
13
17
|
const chunks: Buffer[] = []
|
|
14
18
|
req.on('data', (c) => chunks.push(c))
|
|
15
|
-
req.on('end', () => {
|
|
19
|
+
req.on('end', async () => {
|
|
16
20
|
const body = chunks.length
|
|
17
21
|
? JSON.parse(Buffer.concat(chunks).toString())
|
|
18
22
|
: {}
|
|
19
23
|
if (req.url === '/api/auth/sign-in/actor') {
|
|
20
|
-
if (body.
|
|
24
|
+
if (!(await verifyActorSecret(ROOT, body.email, body.secret))) {
|
|
21
25
|
res
|
|
22
26
|
.writeHead(401)
|
|
23
27
|
.end(JSON.stringify({ message: 'bad actor secret' }))
|
|
@@ -82,7 +86,9 @@ describe('HttpPersona', async () => {
|
|
|
82
86
|
const target = await startTarget()
|
|
83
87
|
after(() => target.server.close())
|
|
84
88
|
|
|
85
|
-
const makePersonas = (
|
|
89
|
+
const makePersonas = (
|
|
90
|
+
secret: Parameters<typeof createHttpPersonas>[0]['secret'] = ROOT
|
|
91
|
+
) =>
|
|
86
92
|
createHttpPersonas({
|
|
87
93
|
apiUrl: target.apiUrl,
|
|
88
94
|
secret,
|
|
@@ -216,8 +222,10 @@ describe('HttpPersona', async () => {
|
|
|
216
222
|
)
|
|
217
223
|
})
|
|
218
224
|
|
|
219
|
-
|
|
220
|
-
|
|
225
|
+
// A caller holding one persona's credential and asking for another gets the
|
|
226
|
+
// target's refusal, not a client-side guess about whether it would have worked.
|
|
227
|
+
test('a credential the target will not accept surfaces status and body', async () => {
|
|
228
|
+
const actors = makePersonas(() => 'not-this-personas-credential')
|
|
221
229
|
await assert.rejects(
|
|
222
230
|
actors.customer!.invoke('ping', {}),
|
|
223
231
|
/persona sign-in failed for 'customer' \(401\).*bad actor secret/
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '../wirings/workflow/scenario-cookie-jar.js'
|
|
19
19
|
import {
|
|
20
20
|
ActorSignIn,
|
|
21
|
+
type ActorSecretResolver,
|
|
21
22
|
OperatorSignIn,
|
|
22
23
|
type OperatorSignInOptions,
|
|
23
24
|
type PersonaSignIn,
|
|
@@ -34,13 +35,19 @@ export interface HttpPersonasConfig {
|
|
|
34
35
|
*/
|
|
35
36
|
apiUrl: string
|
|
36
37
|
/**
|
|
37
|
-
* The
|
|
38
|
-
*
|
|
38
|
+
* The ROOT actor secret, from which each persona's own credential is derived
|
|
39
|
+
* and bound to their address. Sign-in only ever works for user rows flagged
|
|
40
|
+
* `actor: true`, and a derived credential only ever works for the one address
|
|
41
|
+
* it was derived for.
|
|
42
|
+
*
|
|
43
|
+
* Pass an {@link ActorSecretResolver} instead to drive personas whose
|
|
44
|
+
* credentials were minted elsewhere — a caller entitled to one persona then
|
|
45
|
+
* never holds the root.
|
|
39
46
|
*
|
|
40
47
|
* The local-development credential. A deployed stage has none, and passes
|
|
41
48
|
* {@link HttpPersonasConfig.operator} instead.
|
|
42
49
|
*/
|
|
43
|
-
secret?: string
|
|
50
|
+
secret?: string | ActorSecretResolver
|
|
44
51
|
/**
|
|
45
52
|
* Fabric operator credentials, for signing personas into a DEPLOYED stage.
|
|
46
53
|
*
|
package/src/services/index.ts
CHANGED
|
@@ -73,6 +73,14 @@ export {
|
|
|
73
73
|
type WebhookJobData,
|
|
74
74
|
type WebhookServiceConfig,
|
|
75
75
|
} from './webhook-service.js'
|
|
76
|
+
export {
|
|
77
|
+
ACTOR_ROOT_SECRET_MIN_LENGTH,
|
|
78
|
+
ACTOR_SECRET_INFO,
|
|
79
|
+
ACTOR_SECRET_NAME,
|
|
80
|
+
actorSecretSubject,
|
|
81
|
+
deriveActorSecret,
|
|
82
|
+
verifyActorSecret,
|
|
83
|
+
} from './persona-actor-secret.js'
|
|
76
84
|
export type { Logger } from './logger.js'
|
|
77
85
|
export type { SecretService, SecretValues } from './secret-service.js'
|
|
78
86
|
export type { VariablesService } from './variables-service.js'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ACTOR_ROOT_SECRET_MIN_LENGTH,
|
|
6
|
+
deriveActorSecret,
|
|
7
|
+
verifyActorSecret,
|
|
8
|
+
} from './persona-actor-secret.js'
|
|
9
|
+
|
|
10
|
+
const ROOT = 'root-secret-root-secret-root-secret'
|
|
11
|
+
const OTHER_ROOT = 'other-secret-other-secret-other-sec'
|
|
12
|
+
|
|
13
|
+
describe('actor credentials', () => {
|
|
14
|
+
test('a credential verifies for its own address', async () => {
|
|
15
|
+
const secret = await deriveActorSecret(ROOT, 'susan@actors.local')
|
|
16
|
+
assert.equal(
|
|
17
|
+
await verifyActorSecret(ROOT, 'susan@actors.local', secret),
|
|
18
|
+
true
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('and for no other address', async () => {
|
|
23
|
+
const secret = await deriveActorSecret(ROOT, 'susan@actors.local')
|
|
24
|
+
assert.equal(
|
|
25
|
+
await verifyActorSecret(ROOT, 'yasser@actors.local', secret),
|
|
26
|
+
false
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('the address is matched the way the row is looked up', async () => {
|
|
31
|
+
const secret = await deriveActorSecret(ROOT, ' Susan@Actors.Local ')
|
|
32
|
+
assert.equal(
|
|
33
|
+
await verifyActorSecret(ROOT, 'susan@actors.local', secret),
|
|
34
|
+
true
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('rotating the root invalidates every credential at once', async () => {
|
|
39
|
+
const secret = await deriveActorSecret(ROOT, 'susan@actors.local')
|
|
40
|
+
assert.equal(
|
|
41
|
+
await verifyActorSecret(OTHER_ROOT, 'susan@actors.local', secret),
|
|
42
|
+
false
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('the root is not a credential for anybody', async () => {
|
|
47
|
+
assert.equal(
|
|
48
|
+
await verifyActorSecret(ROOT, 'susan@actors.local', ROOT),
|
|
49
|
+
false
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a malformed value is false rather than a throw', async () => {
|
|
54
|
+
assert.equal(await verifyActorSecret(ROOT, 'susan@actors.local', ''), false)
|
|
55
|
+
assert.equal(
|
|
56
|
+
await verifyActorSecret(ROOT, 'susan@actors.local', 'not base64url!!'),
|
|
57
|
+
false
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('key material shorter than the minimum is refused, not silently used', async () => {
|
|
62
|
+
await assert.rejects(
|
|
63
|
+
() => deriveActorSecret('short', 'susan@actors.local'),
|
|
64
|
+
/SCENARIO_ACTOR_SECRET/
|
|
65
|
+
)
|
|
66
|
+
assert.ok(ACTOR_ROOT_SECRET_MIN_LENGTH >= 32)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MIN_KEY_MATERIAL_LENGTH,
|
|
3
|
+
signWithKeyMaterial,
|
|
4
|
+
verifyWithKeyMaterial,
|
|
5
|
+
} from '../crypto-utils.js'
|
|
6
|
+
|
|
7
|
+
/** The name the root secret is held under, used only in error messages. */
|
|
8
|
+
export const ACTOR_SECRET_NAME = 'SCENARIO_ACTOR_SECRET'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Namespaces the derivation so the same root secret used for anything else
|
|
12
|
+
* produces different values. See knowledge/crypto.md.
|
|
13
|
+
*/
|
|
14
|
+
export const ACTOR_SECRET_INFO = 'pikku:actor-sign-in'
|
|
15
|
+
|
|
16
|
+
/** The root must be strong: every persona's credential is derived from it. */
|
|
17
|
+
export const ACTOR_ROOT_SECRET_MIN_LENGTH = MIN_KEY_MATERIAL_LENGTH
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What the derivation is bound to. Lowercased because the sign-in endpoint
|
|
21
|
+
* looks the user up by lowercased address, and a credential that verified
|
|
22
|
+
* against a different string than the row it opens is a credential for nothing.
|
|
23
|
+
*/
|
|
24
|
+
export const actorSecretSubject = (email: string): string =>
|
|
25
|
+
email.trim().toLowerCase()
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One persona's actor credential: `HMAC-SHA256(root, email)`, base64url.
|
|
29
|
+
*
|
|
30
|
+
* The root secret is not itself a valid credential and never travels: what a
|
|
31
|
+
* scenario run, a CI job or a virtual user is handed is the derived value for
|
|
32
|
+
* the one address it is entitled to. Presenting it for any other address fails,
|
|
33
|
+
* so a leaked credential is worth exactly one synthetic account rather than the
|
|
34
|
+
* whole actor population.
|
|
35
|
+
*
|
|
36
|
+
* Deterministic, so nothing is stored and nothing is provisioned — the target
|
|
37
|
+
* re-derives the expected value from the address being signed in as. Rotating
|
|
38
|
+
* the root invalidates every derived credential at once, which is the property
|
|
39
|
+
* a per-persona secret table would have to implement by hand.
|
|
40
|
+
*/
|
|
41
|
+
export const deriveActorSecret = async (
|
|
42
|
+
rootSecret: string,
|
|
43
|
+
email: string
|
|
44
|
+
): Promise<string> =>
|
|
45
|
+
signWithKeyMaterial(
|
|
46
|
+
ACTOR_SECRET_NAME,
|
|
47
|
+
rootSecret,
|
|
48
|
+
ACTOR_SECRET_INFO,
|
|
49
|
+
actorSecretSubject(email)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether `presented` is the credential for `email` under `rootSecret`.
|
|
54
|
+
*
|
|
55
|
+
* False — never throws — for a malformed, truncated or mismatched value, and
|
|
56
|
+
* the comparison is WebCrypto's own HMAC verify, so it does not exit early on
|
|
57
|
+
* the first differing byte.
|
|
58
|
+
*/
|
|
59
|
+
export const verifyActorSecret = async (
|
|
60
|
+
rootSecret: string,
|
|
61
|
+
email: string,
|
|
62
|
+
presented: string
|
|
63
|
+
): Promise<boolean> =>
|
|
64
|
+
verifyWithKeyMaterial(
|
|
65
|
+
ACTOR_SECRET_NAME,
|
|
66
|
+
rootSecret,
|
|
67
|
+
ACTOR_SECRET_INFO,
|
|
68
|
+
actorSecretSubject(email),
|
|
69
|
+
presented
|
|
70
|
+
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deriveActorSecret } from './persona-actor-secret.js'
|
|
1
2
|
import type { ResolvedPersona } from './personas-service.js'
|
|
2
3
|
import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
|
|
3
4
|
|
|
@@ -40,6 +41,14 @@ const failed = async (
|
|
|
40
41
|
)
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Yields the credential for one persona, for a caller that holds that persona's
|
|
46
|
+
* derived secret and not the root it came from.
|
|
47
|
+
*/
|
|
48
|
+
export type ActorSecretResolver = (
|
|
49
|
+
persona: ResolvedPersona
|
|
50
|
+
) => string | Promise<string>
|
|
51
|
+
|
|
43
52
|
/**
|
|
44
53
|
* Sign a persona in through the Better Auth actor plugin — the local-development
|
|
45
54
|
* path.
|
|
@@ -48,22 +57,31 @@ const failed = async (
|
|
|
48
57
|
* for it. Passwordless by design and refused for any row not carrying that flag,
|
|
49
58
|
* so the secret can never reach a real user's account; the plugin still declines
|
|
50
59
|
* to serve the endpoint at all outside `pikku dev`.
|
|
60
|
+
*
|
|
61
|
+
* What is presented is the persona's own credential, derived from the root and
|
|
62
|
+
* bound to their address. A run driving many personas holds the root and
|
|
63
|
+
* derives as it goes; a run entitled to one persona is handed that one value
|
|
64
|
+
* through a resolver and can sign in as nobody else.
|
|
51
65
|
*/
|
|
52
66
|
export class ActorSignIn implements PersonaSignIn {
|
|
53
67
|
constructor(
|
|
54
68
|
private readonly apiUrl: string,
|
|
55
|
-
private readonly secret: string,
|
|
69
|
+
private readonly secret: string | ActorSecretResolver,
|
|
56
70
|
private readonly signInPath: string
|
|
57
71
|
) {}
|
|
58
72
|
|
|
59
73
|
async login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void> {
|
|
74
|
+
const secret =
|
|
75
|
+
typeof this.secret === 'function'
|
|
76
|
+
? await this.secret(persona)
|
|
77
|
+
: await deriveActorSecret(this.secret, persona.email)
|
|
60
78
|
const res = await jar.fetch(`${this.apiUrl}${this.signInPath}`, {
|
|
61
79
|
method: 'POST',
|
|
62
80
|
headers: { 'content-type': 'application/json' },
|
|
63
81
|
body: JSON.stringify({
|
|
64
82
|
email: persona.email,
|
|
65
83
|
name: persona.name,
|
|
66
|
-
secret
|
|
84
|
+
secret,
|
|
67
85
|
}),
|
|
68
86
|
})
|
|
69
87
|
if (!res.ok) {
|
|
@@ -137,7 +137,10 @@ describe('TypedSecretService', () => {
|
|
|
137
137
|
optional: true,
|
|
138
138
|
},
|
|
139
139
|
})
|
|
140
|
-
assert.strictEqual(
|
|
140
|
+
assert.strictEqual(
|
|
141
|
+
await service.getSecret('SCENARIO_ACTOR_SECRET'),
|
|
142
|
+
undefined
|
|
143
|
+
)
|
|
141
144
|
})
|
|
142
145
|
|
|
143
146
|
test('an optional secret that IS set resolves its value', async () => {
|
|
@@ -159,6 +162,28 @@ describe('TypedSecretService', () => {
|
|
|
159
162
|
await assert.rejects(() => service.getSecret('STRIPE_KEY'))
|
|
160
163
|
})
|
|
161
164
|
|
|
165
|
+
test('a resolved-absent optional secret still reports hasSecret false', async () => {
|
|
166
|
+
// The cache stores `undefined` to remember the absence, so a `has(key)`
|
|
167
|
+
// test on it answers "we have looked", not "there is a value". Reading an
|
|
168
|
+
// optional secret must not turn its own absence into a claim it is set.
|
|
169
|
+
const service = new TypedSecretService(createMockSecrets(), {
|
|
170
|
+
OPTIONAL: { name: 'opt', displayName: 'Optional', optional: true },
|
|
171
|
+
})
|
|
172
|
+
assert.strictEqual(await service.getSecret('OPTIONAL'), undefined)
|
|
173
|
+
assert.strictEqual(await service.hasSecret('OPTIONAL'), false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('an optional secret set after an absent read is picked up', async () => {
|
|
177
|
+
const store = new Map<string, string>()
|
|
178
|
+
const service = new TypedSecretService(createMockSecrets(store), {
|
|
179
|
+
OPTIONAL: { name: 'opt', displayName: 'Optional', optional: true },
|
|
180
|
+
})
|
|
181
|
+
assert.strictEqual(await service.getSecret('OPTIONAL'), undefined)
|
|
182
|
+
await service.setSecret('OPTIONAL', 'later')
|
|
183
|
+
assert.strictEqual(await service.hasSecret('OPTIONAL'), true)
|
|
184
|
+
assert.strictEqual(await service.getSecret('OPTIONAL'), 'later')
|
|
185
|
+
})
|
|
186
|
+
|
|
162
187
|
test('an optional secret does not re-hit the store once resolved', async () => {
|
|
163
188
|
let reads = 0
|
|
164
189
|
const base = createMockSecrets()
|
|
@@ -64,8 +64,11 @@ export class TypedSecretService<
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
async hasSecret(key: string): Promise<boolean> {
|
|
67
|
+
// `undefined` is cached for an optional secret that resolved absent, so a
|
|
68
|
+
// cache hit means "already looked", not "there is a value". Reporting true
|
|
69
|
+
// for it would let a read of an optional secret assert its own presence.
|
|
67
70
|
if (this.cache.has(key)) {
|
|
68
|
-
return
|
|
71
|
+
return this.cache.get(key) !== undefined
|
|
69
72
|
}
|
|
70
73
|
return this.secrets.hasSecret(key)
|
|
71
74
|
}
|
|
@@ -22,7 +22,9 @@ const assertSampleRate = (name: string, sampleRate: number | undefined) => {
|
|
|
22
22
|
* @example snippet: agentScorer
|
|
23
23
|
*/
|
|
24
24
|
export const pikkuAgentScorer = <Services = any>(config: {
|
|
25
|
+
/** Identifies the scorer in results and in the Console. Unique per project. */
|
|
25
26
|
name: string
|
|
27
|
+
/** What this scorer grades, in one line, for whoever reads the score later. */
|
|
26
28
|
description: string
|
|
27
29
|
/** 0..1 fraction of live runs to grade. Defaults to all of them. */
|
|
28
30
|
sampleRate?: number
|
|
@@ -31,6 +33,10 @@ export const pikkuAgentScorer = <Services = any>(config: {
|
|
|
31
33
|
* traffic has no answer key, so the runtime never samples it.
|
|
32
34
|
*/
|
|
33
35
|
requiresReference?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* The grade itself: read the finished run and return `{ score, reason }`.
|
|
38
|
+
* Runs in-process, so it may use your own services.
|
|
39
|
+
*/
|
|
34
40
|
score: (
|
|
35
41
|
input: ScorerInput,
|
|
36
42
|
services: Services
|
|
@@ -55,7 +61,9 @@ export const pikkuAgentScorer = <Services = any>(config: {
|
|
|
55
61
|
* @example snippet: agentJudge
|
|
56
62
|
*/
|
|
57
63
|
export const pikkuAgentJudge = <Services = any>(config: {
|
|
64
|
+
/** Identifies the judge in results and in the Console. Unique per project. */
|
|
58
65
|
name: string
|
|
66
|
+
/** What this judge grades, in one line, for whoever reads the score later. */
|
|
59
67
|
description: string
|
|
60
68
|
/** 0..1 fraction of live runs to grade. Defaults to all of them. */
|
|
61
69
|
sampleRate?: number
|
|
@@ -64,7 +72,9 @@ export const pikkuAgentJudge = <Services = any>(config: {
|
|
|
64
72
|
* traffic has no answer key, so the runtime never samples it.
|
|
65
73
|
*/
|
|
66
74
|
requiresReference?: boolean
|
|
75
|
+
/** The model that grades, e.g. `'claude-sonnet-4-5'`. Not the model under test. */
|
|
67
76
|
model: string
|
|
77
|
+
/** The rubric: what a good answer looks like, phrased as the goal it should meet. */
|
|
68
78
|
goal: string
|
|
69
79
|
/**
|
|
70
80
|
* How much of the run's trajectory to disclose to the judge. Defaults to
|
|
@@ -72,6 +82,10 @@ export const pikkuAgentJudge = <Services = any>(config: {
|
|
|
72
82
|
* sending a third-party model the rows the tools returned.
|
|
73
83
|
*/
|
|
74
84
|
toolCalls?: JudgeToolCallDisclosure
|
|
85
|
+
/**
|
|
86
|
+
* Replaces the generated rubric prompt outright, for framing `goal` cannot
|
|
87
|
+
* express. The `{ score, reason }` response is still forced.
|
|
88
|
+
*/
|
|
75
89
|
prompt?: (input: ScorerInput) => string
|
|
76
90
|
}): PikkuAgentScorer<Services> => ({
|
|
77
91
|
name: config.name,
|
|
@@ -24,9 +24,13 @@ export interface GatewayAttachment {
|
|
|
24
24
|
export interface GatewayInboundMessage {
|
|
25
25
|
/** Platform-specific: a phone number, a Slack user id, and so on. */
|
|
26
26
|
senderId: string
|
|
27
|
+
/** What they said, as plain text, with the provider's markup stripped. */
|
|
27
28
|
text: string
|
|
29
|
+
/** The provider's own event, untouched, for anything this shape drops. */
|
|
28
30
|
raw: unknown
|
|
31
|
+
/** Files and media that came with the message. */
|
|
29
32
|
attachments?: GatewayAttachment[]
|
|
33
|
+
/** Anything else the adapter wants to carry through to the wiring. */
|
|
30
34
|
metadata?: Record<string, unknown>
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -35,8 +39,11 @@ export interface GatewayInboundMessage {
|
|
|
35
39
|
* own rich content.
|
|
36
40
|
*/
|
|
37
41
|
export interface GatewayOutboundMessage {
|
|
42
|
+
/** The reply as plain text. Every provider can render this. */
|
|
38
43
|
text?: string
|
|
44
|
+
/** The provider's own rich payload, e.g. Slack blocks. Passed through as-is. */
|
|
39
45
|
richContent?: Record<string, unknown>
|
|
46
|
+
/** Files and media to send alongside. */
|
|
40
47
|
attachments?: GatewayAttachment[]
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -45,19 +52,31 @@ export interface GatewayOutboundMessage {
|
|
|
45
52
|
* provider expects back, or not.
|
|
46
53
|
*/
|
|
47
54
|
export type WebhookVerificationResult =
|
|
48
|
-
|
|
55
|
+
| {
|
|
56
|
+
/** True when the request really came from the provider. */
|
|
57
|
+
verified: true
|
|
58
|
+
/** What to echo back, e.g. Meta's hub.challenge. */
|
|
59
|
+
response: unknown
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
/** False when the signature or challenge did not check out. */
|
|
63
|
+
verified: false
|
|
64
|
+
}
|
|
49
65
|
|
|
50
66
|
/**
|
|
51
67
|
* What a gateway integration implements: parse an incoming event into a
|
|
52
68
|
* message, send one back, and open and close the connection.
|
|
53
69
|
*/
|
|
54
70
|
export interface GatewayAdapter {
|
|
71
|
+
/** Identifies the gateway in wirings and logs, e.g. `'slack'`. */
|
|
55
72
|
name: string
|
|
56
73
|
/** Return null to ignore the event, e.g. a delivery receipt. */
|
|
57
74
|
parse(data: unknown): GatewayInboundMessage | null
|
|
75
|
+
/** Deliver a reply back to the sender the message came from. */
|
|
58
76
|
send(senderId: string, message: GatewayOutboundMessage): Promise<void>
|
|
59
77
|
/** Called by GatewayService.start(); must call onMessage per incoming event. */
|
|
60
78
|
init(onMessage: (data: unknown) => Promise<void>): Promise<void>
|
|
79
|
+
/** Called by GatewayService.stop(); release the connection init() opened. */
|
|
61
80
|
close(): Promise<void>
|
|
62
81
|
/** Receives the GET query params, or the POST body when called from the POST handler. */
|
|
63
82
|
verifyWebhook?(
|
|
@@ -55,10 +55,19 @@ export {
|
|
|
55
55
|
OperatorSignIn,
|
|
56
56
|
establishOperatorSession,
|
|
57
57
|
IMPERSONATE_USER_ID_HEADER,
|
|
58
|
+
type ActorSecretResolver,
|
|
58
59
|
type PersonaSignIn,
|
|
59
60
|
type OperatorSignInOptions,
|
|
60
61
|
type OperatorSessionResult,
|
|
61
62
|
} from '../../services/persona-sign-in.js'
|
|
63
|
+
export {
|
|
64
|
+
ACTOR_ROOT_SECRET_MIN_LENGTH,
|
|
65
|
+
ACTOR_SECRET_INFO,
|
|
66
|
+
ACTOR_SECRET_NAME,
|
|
67
|
+
actorSecretSubject,
|
|
68
|
+
deriveActorSecret,
|
|
69
|
+
verifyActorSecret,
|
|
70
|
+
} from '../../services/persona-actor-secret.js'
|
|
62
71
|
export {
|
|
63
72
|
postScenarioJson,
|
|
64
73
|
readScenarioHttpResponse,
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
export type CoreSecret<T = unknown> = {
|
|
2
|
+
/** The key code reads it by: `secrets.getSecret('NAME')`. SCREAMING_SNAKE_CASE. */
|
|
2
3
|
name: string
|
|
4
|
+
/** How the secret is labelled wherever a person is asked to supply it. */
|
|
3
5
|
displayName: string
|
|
6
|
+
/** What this secret is for, shown beside the field someone has to fill in. */
|
|
4
7
|
description?: string
|
|
8
|
+
/** The id under the backing store, which is where the value actually lives. */
|
|
5
9
|
secretId: string
|
|
10
|
+
/**
|
|
11
|
+
* The shape of the value, as a schema. This is what types `getSecret`'s
|
|
12
|
+
* result — pass the schema itself, not an instance of it.
|
|
13
|
+
*/
|
|
6
14
|
schema: T
|
|
7
15
|
/** Required by default: this says absence is a supported state, and `getSecret` resolves `undefined` rather than throwing. */
|
|
8
16
|
optional?: boolean
|