@pikku/inspector 0.12.56 → 0.12.59
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 +185 -0
- package/dist/add/add-ai-agent.js +25 -0
- package/dist/add/add-ai-scorer.d.ts +2 -0
- package/dist/add/add-ai-scorer.js +61 -0
- package/dist/add/add-wire-addon.js +26 -0
- package/dist/add/scenario-instrumentation.d.ts +6 -1
- package/dist/add/scenario-instrumentation.js +6 -0
- package/dist/error-codes.d.ts +2 -0
- package/dist/error-codes.js +3 -0
- package/dist/inspector.js +7 -3
- package/dist/types.d.ts +24 -0
- package/dist/utils/filter-inspector-state.js +8 -0
- package/dist/utils/post-process.d.ts +13 -3
- package/dist/utils/post-process.js +51 -21
- package/dist/utils/serialize-inspector-state.d.ts +11 -0
- package/dist/utils/serialize-inspector-state.js +8 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +13 -4
- package/dist/utils/workflow/dsl/patterns.d.ts +2 -1
- package/dist/utils/workflow/dsl/patterns.js +8 -5
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +2 -1
- package/dist/visit.js +2 -0
- package/package.json +3 -3
- package/src/add/add-ai-agent.ts +35 -0
- package/src/add/add-ai-scorer.test.ts +197 -0
- package/src/add/add-ai-scorer.ts +83 -0
- package/src/add/add-functions.ts +1 -1
- package/src/add/add-scenario-step.test.ts +21 -1
- package/src/add/add-wire-addon.test.ts +74 -0
- package/src/add/add-wire-addon.ts +22 -0
- package/src/add/scenario-instrumentation.ts +6 -0
- package/src/error-codes.ts +4 -0
- package/src/inspector.ts +7 -3
- package/src/types.ts +21 -0
- package/src/utils/filter-inspector-state.ts +8 -0
- package/src/utils/post-process.test.ts +138 -0
- package/src/utils/post-process.ts +69 -22
- package/src/utils/serialize-inspector-state.ts +16 -0
- package/src/utils/workflow/dsl/extract-approval-policy.test.ts +68 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +13 -5
- package/src/utils/workflow/dsl/patterns.ts +8 -5
- package/src/utils/workflow/graph/workflow-graph.types.ts +1 -1
- package/src/visit.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/utils/secret-brokers.test.ts +0 -53
- package/src/utils/secret-brokers.ts +0 -11
|
@@ -5,7 +5,8 @@ import { tmpdir } from 'node:os'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { inspect } from '../inspector.js'
|
|
7
7
|
import type { InspectorLogger } from '../types.js'
|
|
8
|
-
import type {
|
|
8
|
+
import type { WorkflowStepMeta } from '@pikku/core/workflow'
|
|
9
|
+
import type { ScenarioStepMeta } from '@pikku/core/scenario'
|
|
9
10
|
|
|
10
11
|
function makeLogger(
|
|
11
12
|
criticals: Array<{ code: string; message: string }>
|
|
@@ -243,6 +244,25 @@ describe('pikkuScenarioStep', () => {
|
|
|
243
244
|
}
|
|
244
245
|
})
|
|
245
246
|
|
|
247
|
+
test('a graded agent run is a witness too', async () => {
|
|
248
|
+
// An agent's answer cannot be matched against a fixed string, so a scenario
|
|
249
|
+
// that grades one is asserting — even though `expectScore`, like the other
|
|
250
|
+
// helpers, is an inline step that reaches PKU680 as nothing at all.
|
|
251
|
+
const { criticals, cleanup } = await run([
|
|
252
|
+
"const answer = await scenario.when('asks for a summary', 'runAssistant', { prompt: 'hi' }, { actor: actors.shopper })",
|
|
253
|
+
"await scenario.expectScore('answered briefly', answer.runId, 'brevity', { atLeast: 0.8 })",
|
|
254
|
+
])
|
|
255
|
+
try {
|
|
256
|
+
assert.equal(
|
|
257
|
+
criticals.filter((c) => c.code === 'PKU680').length,
|
|
258
|
+
0,
|
|
259
|
+
`got: ${JSON.stringify(criticals)}`
|
|
260
|
+
)
|
|
261
|
+
} finally {
|
|
262
|
+
await cleanup()
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
246
266
|
test('an expectation parked in an uncalled helper is still no assertion', async () => {
|
|
247
267
|
const { criticals, cleanup } = await run([
|
|
248
268
|
'const neverCalled = async () => {',
|
|
@@ -61,9 +61,83 @@ describe('addWireAddon', () => {
|
|
|
61
61
|
secretOverrides: undefined,
|
|
62
62
|
variableOverrides: undefined,
|
|
63
63
|
credentialOverrides: undefined,
|
|
64
|
+
secretGrants: undefined,
|
|
65
|
+
credentialGrants: undefined,
|
|
66
|
+
globalSecrets: undefined,
|
|
67
|
+
globalCredentials: undefined,
|
|
64
68
|
})
|
|
65
69
|
})
|
|
66
70
|
|
|
71
|
+
test('captures the secrets and credentials the host lends an addon', () => {
|
|
72
|
+
const declarations = inspect(`
|
|
73
|
+
wireAddon({
|
|
74
|
+
name: 'graph',
|
|
75
|
+
package: '@pikku/addon-graph',
|
|
76
|
+
secretGrants: ['STRIPE_KEY', 'GITHUB_TOKEN'],
|
|
77
|
+
credentialGrants: ['slack'],
|
|
78
|
+
})
|
|
79
|
+
`)
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(declarations.get('graph').secretGrants, [
|
|
82
|
+
'STRIPE_KEY',
|
|
83
|
+
'GITHUB_TOKEN',
|
|
84
|
+
])
|
|
85
|
+
assert.deepEqual(declarations.get('graph').credentialGrants, ['slack'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('drops a grant list that is not statically knowable', () => {
|
|
89
|
+
const declarations = inspect(`
|
|
90
|
+
wireAddon({
|
|
91
|
+
name: 'graph',
|
|
92
|
+
package: '@pikku/addon-graph',
|
|
93
|
+
secretGrants: someRuntimeList,
|
|
94
|
+
})
|
|
95
|
+
`)
|
|
96
|
+
|
|
97
|
+
assert.equal(declarations.get('graph').secretGrants, undefined)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('captures the reason an addon was exempted from credential scoping', () => {
|
|
101
|
+
const declarations = inspect(`
|
|
102
|
+
wireAddon({
|
|
103
|
+
name: 'console',
|
|
104
|
+
package: '@pikku/addon-console',
|
|
105
|
+
globalCredentials: 'links credentials an operator names at runtime',
|
|
106
|
+
})
|
|
107
|
+
`)
|
|
108
|
+
|
|
109
|
+
assert.equal(
|
|
110
|
+
declarations.get('console').globalCredentials,
|
|
111
|
+
'links credentials an operator names at runtime'
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('captures the reason an addon was exempted from secret scoping', () => {
|
|
116
|
+
const declarations = inspect(`
|
|
117
|
+
wireAddon({
|
|
118
|
+
name: 'console',
|
|
119
|
+
package: '@pikku/addon-console',
|
|
120
|
+
globalSecrets: 'administers secrets an operator names at runtime',
|
|
121
|
+
})
|
|
122
|
+
`)
|
|
123
|
+
|
|
124
|
+
assert.equal(
|
|
125
|
+
declarations.get('console').globalSecrets,
|
|
126
|
+
'administers secrets an operator names at runtime'
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('a globalSecrets reason that is not a literal still reports the grant', () => {
|
|
131
|
+
// Unlike `scopes`, dropping this would under-report: the grant is real at
|
|
132
|
+
// runtime whatever the reason evaluates to. The source text is recorded so
|
|
133
|
+
// the manifest names the addon and points at where the reason comes from.
|
|
134
|
+
const declarations = inspect(`
|
|
135
|
+
wireAddon({ name: 'console', package: '@x/y', globalSecrets: REASON })
|
|
136
|
+
`)
|
|
137
|
+
|
|
138
|
+
assert.equal(declarations.get('console').globalSecrets, 'REASON')
|
|
139
|
+
})
|
|
140
|
+
|
|
67
141
|
test('leaves the gates undefined when none are declared', () => {
|
|
68
142
|
const declarations = inspect(`
|
|
69
143
|
wireAddon({ name: 'console', package: '@pikku/addon-console' })
|
|
@@ -60,6 +60,10 @@ export function addWireAddon(
|
|
|
60
60
|
let secretOverrides: Record<string, string> | undefined
|
|
61
61
|
let variableOverrides: Record<string, string> | undefined
|
|
62
62
|
let credentialOverrides: Record<string, string> | undefined
|
|
63
|
+
let secretGrants: string[] | undefined
|
|
64
|
+
let credentialGrants: string[] | undefined
|
|
65
|
+
let globalSecrets: string | undefined
|
|
66
|
+
let globalCredentials: string | undefined
|
|
63
67
|
|
|
64
68
|
for (const prop of firstArg.properties) {
|
|
65
69
|
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
|
|
@@ -102,6 +106,20 @@ export function addWireAddon(
|
|
|
102
106
|
ts.isObjectLiteralExpression(prop.initializer)
|
|
103
107
|
) {
|
|
104
108
|
credentialOverrides = parseStringRecord(prop.initializer)
|
|
109
|
+
} else if (key === 'secretGrants') {
|
|
110
|
+
secretGrants = parseStringArray(prop.initializer)
|
|
111
|
+
} else if (key === 'credentialGrants') {
|
|
112
|
+
credentialGrants = parseStringArray(prop.initializer)
|
|
113
|
+
} else if (key === 'globalSecrets') {
|
|
114
|
+
// The grant is real at runtime whatever the reason evaluates to, so a
|
|
115
|
+
// non-literal is recorded as its source text rather than dropped.
|
|
116
|
+
globalSecrets = ts.isStringLiteral(prop.initializer)
|
|
117
|
+
? prop.initializer.text
|
|
118
|
+
: prop.initializer.getText()
|
|
119
|
+
} else if (key === 'globalCredentials') {
|
|
120
|
+
globalCredentials = ts.isStringLiteral(prop.initializer)
|
|
121
|
+
? prop.initializer.text
|
|
122
|
+
: prop.initializer.getText()
|
|
105
123
|
}
|
|
106
124
|
}
|
|
107
125
|
|
|
@@ -118,6 +136,10 @@ export function addWireAddon(
|
|
|
118
136
|
secretOverrides,
|
|
119
137
|
variableOverrides,
|
|
120
138
|
credentialOverrides,
|
|
139
|
+
secretGrants,
|
|
140
|
+
credentialGrants,
|
|
141
|
+
globalSecrets,
|
|
142
|
+
globalCredentials,
|
|
121
143
|
})
|
|
122
144
|
state.rpc.usedAddons.add(name)
|
|
123
145
|
state.rpc.wireAddonFiles.add(node.getSourceFile().fileName)
|
|
@@ -13,12 +13,18 @@
|
|
|
13
13
|
* `pikku dev` registers the implementations itself. Ignoring by name (rather
|
|
14
14
|
* than deleting the scaffold and requiring every project to regenerate) is what
|
|
15
15
|
* keeps an existing project's checked-in `scenarios.gen.ts` from deploying.
|
|
16
|
+
*
|
|
17
|
+
* `pikkuScenarioGradeRun` was never scaffolded — it arrived after the split —
|
|
18
|
+
* but it belongs here for the same reason: grading a run on demand, ignoring the
|
|
19
|
+
* sample rate that governs live traffic, is a thing you do to a development
|
|
20
|
+
* server, and the name is reserved so an application cannot claim it.
|
|
16
21
|
*/
|
|
17
22
|
export const SCENARIO_INSTRUMENTATION_FUNCTIONS = [
|
|
18
23
|
'pikkuScenarioTakeLiveCoverage',
|
|
19
24
|
'pikkuScenarioResetLiveCoverage',
|
|
20
25
|
'pikkuScenarioResetStubs',
|
|
21
26
|
'pikkuScenarioGetStubCalls',
|
|
27
|
+
'pikkuScenarioGradeRun',
|
|
22
28
|
] as const
|
|
23
29
|
|
|
24
30
|
export type ScenarioInstrumentationFunction =
|
package/src/error-codes.ts
CHANGED
|
@@ -91,6 +91,9 @@ export enum ErrorCode {
|
|
|
91
91
|
AGENT_TOOL_NOT_FOUND = 'PKU153',
|
|
92
92
|
AGENT_TOOL_MISSING_DESCRIPTION = 'PKU154',
|
|
93
93
|
|
|
94
|
+
// Agent scorer reference errors
|
|
95
|
+
AGENT_SCORER_NOT_FOUND = 'PKU155',
|
|
96
|
+
|
|
94
97
|
// File structure errors
|
|
95
98
|
SCHEMA_AND_WIRING_COLOCATED = 'PKU490',
|
|
96
99
|
|
|
@@ -102,6 +105,7 @@ export enum ErrorCode {
|
|
|
102
105
|
// Dependency integrity errors
|
|
103
106
|
DUPLICATE_CORE_VERSION = 'PKU717',
|
|
104
107
|
CORE_VERSION_SKEW = 'PKU718',
|
|
108
|
+
SPLIT_TYPE_IDENTITY = 'PKU719',
|
|
105
109
|
|
|
106
110
|
// Data classification errors
|
|
107
111
|
PII_IN_OUTPUT = 'PKU910',
|
package/src/inspector.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
stampAuthHandlerServices,
|
|
17
17
|
validateAgentModels,
|
|
18
18
|
validateAgentToolReferences,
|
|
19
|
+
validateAgentScorerReferences,
|
|
19
20
|
validateSecretOverrides,
|
|
20
21
|
validateVariableOverrides,
|
|
21
22
|
validateCredentialOverrides,
|
|
@@ -28,7 +29,6 @@ import {
|
|
|
28
29
|
computeMiddlewareGroupsMeta,
|
|
29
30
|
computePermissionsGroupsMeta,
|
|
30
31
|
computeRequiredSchemas,
|
|
31
|
-
computeSecretBrokers,
|
|
32
32
|
validateNoSecretAliasServices,
|
|
33
33
|
validateSecretUsage,
|
|
34
34
|
computeDiagnostics,
|
|
@@ -160,6 +160,10 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
160
160
|
agentsMeta: {},
|
|
161
161
|
files: new Map(),
|
|
162
162
|
},
|
|
163
|
+
scorers: {
|
|
164
|
+
scorersMeta: {},
|
|
165
|
+
files: new Map(),
|
|
166
|
+
},
|
|
163
167
|
cli: {
|
|
164
168
|
meta: {
|
|
165
169
|
programs: {},
|
|
@@ -484,7 +488,6 @@ export const inspect = async (
|
|
|
484
488
|
computeResolvedIOTypes(state)
|
|
485
489
|
computeMiddlewareGroupsMeta(state)
|
|
486
490
|
computePermissionsGroupsMeta(state)
|
|
487
|
-
computeSecretBrokers(state)
|
|
488
491
|
validateNoSecretAliasServices(logger, checker, state)
|
|
489
492
|
validateSecretUsage(logger, state)
|
|
490
493
|
computeDiagnostics(state)
|
|
@@ -511,8 +514,9 @@ export const inspect = async (
|
|
|
511
514
|
)
|
|
512
515
|
}
|
|
513
516
|
|
|
514
|
-
validateAgentModels(logger, state)
|
|
517
|
+
validateAgentModels(logger, state, options.modelAliases)
|
|
515
518
|
validateAgentToolReferences(logger, state, options)
|
|
519
|
+
validateAgentScorerReferences(logger, state)
|
|
516
520
|
validateSecretOverrides(logger, state)
|
|
517
521
|
validateVariableOverrides(logger, state)
|
|
518
522
|
validateCredentialOverrides(logger, state)
|
package/src/types.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
MCPPromptMeta,
|
|
14
14
|
} from '@pikku/core/mcp'
|
|
15
15
|
import type { AIAgentMeta } from '@pikku/core/ai-agent'
|
|
16
|
+
import type { ScorerMeta } from '@pikku/core/ai-scorer'
|
|
16
17
|
import type { CLIMeta } from '@pikku/core/cli'
|
|
17
18
|
import type { CLICommandMeta } from '@pikku/core/cli'
|
|
18
19
|
import type { NodesMeta } from '@pikku/core/node'
|
|
@@ -298,6 +299,8 @@ export type InspectorOptions = Partial<{
|
|
|
298
299
|
* tool's own name, so this is a quality gate rather than a correctness one.
|
|
299
300
|
*/
|
|
300
301
|
strictMeta: boolean
|
|
302
|
+
/** The `models` table from pikku.config.json, alias -> `provider/model`. */
|
|
303
|
+
modelAliases: Record<string, string>
|
|
301
304
|
sourceFile: ts.SourceFile
|
|
302
305
|
/**
|
|
303
306
|
* The project's tsconfig, read for its path mappings only. The inspector's
|
|
@@ -585,6 +588,20 @@ export interface InspectorState {
|
|
|
585
588
|
secretOverrides?: Record<string, string>
|
|
586
589
|
variableOverrides?: Record<string, string>
|
|
587
590
|
credentialOverrides?: Record<string, string>
|
|
591
|
+
/** Secrets the app lends this instance, named as the addon reads them. */
|
|
592
|
+
secretGrants?: string[]
|
|
593
|
+
/** Credentials the app lends this instance, named as the addon reads them. */
|
|
594
|
+
credentialGrants?: string[]
|
|
595
|
+
/**
|
|
596
|
+
* The app's stated reason for handing this instance the whole
|
|
597
|
+
* `SecretService` instead of one scoped to its declared secrets.
|
|
598
|
+
*/
|
|
599
|
+
globalSecrets?: string
|
|
600
|
+
/**
|
|
601
|
+
* The app's stated reason for handing this instance the whole
|
|
602
|
+
* `CredentialService` instead of one scoped to its declared credentials.
|
|
603
|
+
*/
|
|
604
|
+
globalCredentials?: string
|
|
588
605
|
}
|
|
589
606
|
>
|
|
590
607
|
wireAddonFiles: Set<string>
|
|
@@ -599,6 +616,10 @@ export interface InspectorState {
|
|
|
599
616
|
agentsMeta: AIAgentMeta
|
|
600
617
|
files: Map<string, { path: string; exportedName: string }>
|
|
601
618
|
}
|
|
619
|
+
scorers: {
|
|
620
|
+
scorersMeta: ScorerMeta
|
|
621
|
+
files: Map<string, { path: string; exportedName: string }>
|
|
622
|
+
}
|
|
602
623
|
cli: {
|
|
603
624
|
meta: CLIMeta
|
|
604
625
|
files: Set<string>
|
|
@@ -419,6 +419,14 @@ export function filterInspectorState(
|
|
|
419
419
|
agentsMeta: JSON.parse(JSON.stringify(state.agents?.agentsMeta ?? {})),
|
|
420
420
|
files: new Map(),
|
|
421
421
|
},
|
|
422
|
+
// Scorers carry through every filter. They are named by the agents that
|
|
423
|
+
// survive it, and a deployment that quietly stopped grading would look
|
|
424
|
+
// exactly like one whose scorers all returned nothing.
|
|
425
|
+
scorers: {
|
|
426
|
+
...state.scorers,
|
|
427
|
+
scorersMeta: JSON.parse(JSON.stringify(state.scorers?.scorersMeta ?? {})),
|
|
428
|
+
files: new Map(state.scorers?.files ?? []),
|
|
429
|
+
},
|
|
422
430
|
rpc: {
|
|
423
431
|
...state.rpc,
|
|
424
432
|
internalMeta: { ...state.rpc.internalMeta }, // Clone to avoid mutating original
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
validateRemoteAddonDependencies,
|
|
12
12
|
validateRemoteAddonAuth,
|
|
13
13
|
validateAgentToolReferences,
|
|
14
|
+
validateAgentModels,
|
|
14
15
|
} from './post-process.js'
|
|
15
16
|
import { ErrorCode } from '../error-codes.js'
|
|
16
17
|
import type { InspectorState, InspectorLogger } from '../types.js'
|
|
@@ -58,6 +59,33 @@ const makeOverrideState = (
|
|
|
58
59
|
},
|
|
59
60
|
}) as unknown as Omit<InspectorState, 'typesLookup'>
|
|
60
61
|
|
|
62
|
+
const makeGrantState = (
|
|
63
|
+
kind: 'secrets' | 'credentials',
|
|
64
|
+
declaredNames: string[],
|
|
65
|
+
decl: {
|
|
66
|
+
secretGrants?: string[]
|
|
67
|
+
credentialGrants?: string[]
|
|
68
|
+
secretOverrides?: Record<string, string>
|
|
69
|
+
credentialOverrides?: Record<string, string>
|
|
70
|
+
}
|
|
71
|
+
): Omit<InspectorState, 'typesLookup'> =>
|
|
72
|
+
({
|
|
73
|
+
rpc: {
|
|
74
|
+
wireAddonDeclarations: new Map([
|
|
75
|
+
['slack-marketing', { package: '@addon/slack', ...decl }],
|
|
76
|
+
]),
|
|
77
|
+
},
|
|
78
|
+
secrets: {
|
|
79
|
+
definitions:
|
|
80
|
+
kind === 'secrets' ? declaredNames.map((name) => ({ name })) : [],
|
|
81
|
+
},
|
|
82
|
+
credentials: {
|
|
83
|
+
definitions:
|
|
84
|
+
kind === 'credentials' ? declaredNames.map((name) => ({ name })) : [],
|
|
85
|
+
},
|
|
86
|
+
variables: { definitions: [] },
|
|
87
|
+
}) as unknown as Omit<InspectorState, 'typesLookup'>
|
|
88
|
+
|
|
61
89
|
function makeState(
|
|
62
90
|
overrides: {
|
|
63
91
|
usedFunctions?: string[]
|
|
@@ -413,6 +441,46 @@ describe('override validation resolves the override target (value), not the logi
|
|
|
413
441
|
validateVariableOverrides(logger, state)
|
|
414
442
|
assert.deepEqual(criticals, [])
|
|
415
443
|
})
|
|
444
|
+
|
|
445
|
+
test('validateSecretOverrides accepts a grant naming a declared secret', () => {
|
|
446
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
447
|
+
const state = makeGrantState('secrets', ['STRIPE_KEY'], {
|
|
448
|
+
secretGrants: ['STRIPE_KEY'],
|
|
449
|
+
})
|
|
450
|
+
validateSecretOverrides(logger, state)
|
|
451
|
+
assert.deepEqual(criticals, [])
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('validateSecretOverrides flags a grant naming no declared secret', () => {
|
|
455
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
456
|
+
const state = makeGrantState('secrets', ['STRIPE_KEY'], {
|
|
457
|
+
secretGrants: ['STIRPE_KEY'],
|
|
458
|
+
})
|
|
459
|
+
validateSecretOverrides(logger, state)
|
|
460
|
+
assert.equal(criticals.length, 1)
|
|
461
|
+
assert.match(criticals[0]!.message, /STIRPE_KEY/)
|
|
462
|
+
assert.match(criticals[0]!.message, /STRIPE_KEY/)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('validateSecretOverrides resolves a grant through its override before looking it up', () => {
|
|
466
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
467
|
+
const state = makeGrantState('secrets', ['PROD_EMAIL_KEY'], {
|
|
468
|
+
secretGrants: ['MAILGUN_KEY'],
|
|
469
|
+
secretOverrides: { MAILGUN_KEY: 'PROD_EMAIL_KEY' },
|
|
470
|
+
})
|
|
471
|
+
validateSecretOverrides(logger, state)
|
|
472
|
+
assert.deepEqual(criticals, [])
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('validateCredentialOverrides flags a grant naming no declared credential', () => {
|
|
476
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
477
|
+
const state = makeGrantState('credentials', ['marketing_cred'], {
|
|
478
|
+
credentialGrants: ['ghost_cred'],
|
|
479
|
+
})
|
|
480
|
+
validateCredentialOverrides(logger, state)
|
|
481
|
+
assert.equal(criticals.length, 1)
|
|
482
|
+
assert.match(criticals[0]!.message, /ghost_cred/)
|
|
483
|
+
})
|
|
416
484
|
})
|
|
417
485
|
|
|
418
486
|
const makeRemoteState = (
|
|
@@ -713,3 +781,73 @@ describe('validateAgentToolReferences (ref() resolved at build time)', () => {
|
|
|
713
781
|
assert.deepEqual(criticals, [])
|
|
714
782
|
})
|
|
715
783
|
})
|
|
784
|
+
|
|
785
|
+
const makeModelState = (
|
|
786
|
+
models: Record<string, string>
|
|
787
|
+
): Omit<InspectorState, 'typesLookup'> =>
|
|
788
|
+
({
|
|
789
|
+
agents: {
|
|
790
|
+
agentsMeta: Object.fromEntries(
|
|
791
|
+
Object.entries(models).map(([name, model]) => [
|
|
792
|
+
name,
|
|
793
|
+
{ name, model, sourceFile: `src/${name}.agent.ts` },
|
|
794
|
+
])
|
|
795
|
+
),
|
|
796
|
+
},
|
|
797
|
+
}) as unknown as Omit<InspectorState, 'typesLookup'>
|
|
798
|
+
|
|
799
|
+
describe('validateAgentModels (aliases resolve against pikku.config.json)', () => {
|
|
800
|
+
test('a provider-qualified model needs no alias', () => {
|
|
801
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
802
|
+
validateAgentModels(logger, makeModelState({ triage: 'openai/gpt-5-mini' }))
|
|
803
|
+
assert.deepEqual(criticals, [])
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
test('a missing model is still reported', () => {
|
|
807
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
808
|
+
validateAgentModels(logger, makeModelState({ triage: '' }))
|
|
809
|
+
assert.equal(criticals.length, 1)
|
|
810
|
+
assert.equal(criticals[0]!.code, ErrorCode.MISSING_MODEL)
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
test('a bare name that the models table defines is accepted', () => {
|
|
814
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
815
|
+
validateAgentModels(logger, makeModelState({ triage: 'cheap' }), {
|
|
816
|
+
cheap: 'openai/gpt-5-mini',
|
|
817
|
+
})
|
|
818
|
+
assert.deepEqual(criticals, [])
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
test('a bare name with no alias is an error naming the ones that exist', () => {
|
|
822
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
823
|
+
validateAgentModels(logger, makeModelState({ triage: 'exspensive' }), {
|
|
824
|
+
cheap: 'openai/gpt-5-mini',
|
|
825
|
+
expensive: 'openai/gpt-5',
|
|
826
|
+
})
|
|
827
|
+
assert.equal(criticals.length, 1)
|
|
828
|
+
assert.equal(criticals[0]!.code, ErrorCode.INVALID_MODEL)
|
|
829
|
+
assert.match(criticals[0]!.message, /cheap, expensive/)
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
test('with no models table a bare name still fails, as it always has', () => {
|
|
833
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
834
|
+
validateAgentModels(logger, makeModelState({ triage: 'gpt-5-mini' }))
|
|
835
|
+
assert.equal(criticals.length, 1)
|
|
836
|
+
assert.equal(criticals[0]!.code, ErrorCode.INVALID_MODEL)
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
for (const inherited of ['toString', 'constructor', '__proto__']) {
|
|
840
|
+
test(`a bare '${inherited}' is not an alias just because Object has one`, () => {
|
|
841
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
842
|
+
validateAgentModels(logger, makeModelState({ triage: inherited }), {
|
|
843
|
+
cheap: 'openai/gpt-5-mini',
|
|
844
|
+
})
|
|
845
|
+
assert.equal(
|
|
846
|
+
criticals.length,
|
|
847
|
+
1,
|
|
848
|
+
'an inherited property name must not read as a configured alias'
|
|
849
|
+
)
|
|
850
|
+
assert.equal(criticals[0]!.code, ErrorCode.INVALID_MODEL)
|
|
851
|
+
})
|
|
852
|
+
}
|
|
853
|
+
})
|
|
@@ -15,7 +15,6 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
15
15
|
import { join } from 'node:path'
|
|
16
16
|
import { extractTypeKeys } from './type-utils.js'
|
|
17
17
|
import { ErrorCode } from '../error-codes.js'
|
|
18
|
-
import { isSecretBrokerFunction } from './secret-brokers.js'
|
|
19
18
|
import { findSecretAliasServices } from './secret-alias-services.js'
|
|
20
19
|
import { relative } from 'node:path'
|
|
21
20
|
import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
|
|
@@ -384,10 +383,8 @@ export function validateSecretOverrides(
|
|
|
384
383
|
)
|
|
385
384
|
|
|
386
385
|
for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
|
|
387
|
-
if (!addonDecl.secretOverrides) continue
|
|
388
|
-
|
|
389
386
|
for (const [logicalName, resolvedName] of Object.entries(
|
|
390
|
-
addonDecl.secretOverrides
|
|
387
|
+
addonDecl.secretOverrides ?? {}
|
|
391
388
|
)) {
|
|
392
389
|
if (!secretIds.has(resolvedName)) {
|
|
393
390
|
const availableSecrets = Array.from(secretIds)
|
|
@@ -397,6 +394,20 @@ export function validateSecretOverrides(
|
|
|
397
394
|
)
|
|
398
395
|
}
|
|
399
396
|
}
|
|
397
|
+
|
|
398
|
+
// A grant names the secret as the addon reads it, so it resolves through
|
|
399
|
+
// the override map before it can be looked up in the project.
|
|
400
|
+
for (const logicalName of addonDecl.secretGrants ?? []) {
|
|
401
|
+
const resolvedName =
|
|
402
|
+
addonDecl.secretOverrides?.[logicalName] ?? logicalName
|
|
403
|
+
if (!secretIds.has(resolvedName)) {
|
|
404
|
+
const availableSecrets = Array.from(secretIds)
|
|
405
|
+
logger.critical(
|
|
406
|
+
ErrorCode.INVALID_VALUE,
|
|
407
|
+
`Secret grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
400
411
|
}
|
|
401
412
|
}
|
|
402
413
|
|
|
@@ -412,10 +423,8 @@ export function validateCredentialOverrides(
|
|
|
412
423
|
)
|
|
413
424
|
|
|
414
425
|
for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
|
|
415
|
-
if (!addonDecl.credentialOverrides) continue
|
|
416
|
-
|
|
417
426
|
for (const [logicalName, resolvedName] of Object.entries(
|
|
418
|
-
addonDecl.credentialOverrides
|
|
427
|
+
addonDecl.credentialOverrides ?? {}
|
|
419
428
|
)) {
|
|
420
429
|
if (!credentialNames.has(resolvedName)) {
|
|
421
430
|
const availableCredentials = Array.from(credentialNames)
|
|
@@ -425,6 +434,18 @@ export function validateCredentialOverrides(
|
|
|
425
434
|
)
|
|
426
435
|
}
|
|
427
436
|
}
|
|
437
|
+
|
|
438
|
+
for (const logicalName of addonDecl.credentialGrants ?? []) {
|
|
439
|
+
const resolvedName =
|
|
440
|
+
addonDecl.credentialOverrides?.[logicalName] ?? logicalName
|
|
441
|
+
if (!credentialNames.has(resolvedName)) {
|
|
442
|
+
const availableCredentials = Array.from(credentialNames)
|
|
443
|
+
logger.critical(
|
|
444
|
+
ErrorCode.INVALID_VALUE,
|
|
445
|
+
`Credential grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
428
449
|
}
|
|
429
450
|
}
|
|
430
451
|
|
|
@@ -746,9 +767,14 @@ export function validateSchemaReferences(
|
|
|
746
767
|
})
|
|
747
768
|
}
|
|
748
769
|
|
|
770
|
+
/**
|
|
771
|
+
* A model is either concrete (`provider/model`) or an alias, which must appear
|
|
772
|
+
* in the `models` table from pikku.config.json.
|
|
773
|
+
*/
|
|
749
774
|
export function validateAgentModels(
|
|
750
775
|
logger: InspectorLogger,
|
|
751
|
-
state: InspectorState | Omit<InspectorState, 'typesLookup'
|
|
776
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>,
|
|
777
|
+
modelAliases: Record<string, string> = {}
|
|
752
778
|
): void {
|
|
753
779
|
for (const [, meta] of Object.entries(state.agents.agentsMeta)) {
|
|
754
780
|
const model = meta.model
|
|
@@ -759,10 +785,17 @@ export function validateAgentModels(
|
|
|
759
785
|
)
|
|
760
786
|
continue
|
|
761
787
|
}
|
|
762
|
-
|
|
788
|
+
// `hasOwn`, not a truthy lookup: every object literal inherits `toString`,
|
|
789
|
+
// `constructor` and `__proto__`, so a bare model named after one of them
|
|
790
|
+
// would read as a configured alias and pass a check nothing else repeats.
|
|
791
|
+
if (!model.includes('/') && !Object.hasOwn(modelAliases, model)) {
|
|
792
|
+
const known = Object.keys(modelAliases).sort()
|
|
763
793
|
logger.critical(
|
|
764
794
|
ErrorCode.INVALID_MODEL,
|
|
765
|
-
`AI agent '${meta.name}' uses model '${model}', which
|
|
795
|
+
`AI agent '${meta.name}' uses model '${model}', which is neither provider-qualified as '<provider>/<model>' (e.g. 'openai/gpt-4') nor an alias in the "models" table of pikku.config.json. ` +
|
|
796
|
+
(known.length
|
|
797
|
+
? `Known aliases: ${known.join(', ')}.`
|
|
798
|
+
: `No "models" aliases are configured.`)
|
|
766
799
|
)
|
|
767
800
|
}
|
|
768
801
|
}
|
|
@@ -982,15 +1015,6 @@ export function validateSchemaWiringSeparation(
|
|
|
982
1015
|
}
|
|
983
1016
|
}
|
|
984
1017
|
|
|
985
|
-
/** Marks the functions that keep the full `SecretService`. */
|
|
986
|
-
export function computeSecretBrokers(state: InspectorState): void {
|
|
987
|
-
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
988
|
-
if (isSecretBrokerFunction(meta.pikkuFuncId ?? id)) {
|
|
989
|
-
meta.secretBroker = true
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
1018
|
/**
|
|
995
1019
|
* Refuses a singleton service that is a `SecretService` under another name.
|
|
996
1020
|
*
|
|
@@ -1029,9 +1053,6 @@ export function validateNoSecretAliasServices(
|
|
|
1029
1053
|
}
|
|
1030
1054
|
|
|
1031
1055
|
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
1032
|
-
if (meta.secretBroker) {
|
|
1033
|
-
continue
|
|
1034
|
-
}
|
|
1035
1056
|
report('Function', id, meta.services?.services ?? [])
|
|
1036
1057
|
}
|
|
1037
1058
|
for (const [id, def] of Object.entries(state.permissions.definitions)) {
|
|
@@ -1284,6 +1305,32 @@ export function validateAgentToolReferences(
|
|
|
1284
1305
|
}
|
|
1285
1306
|
}
|
|
1286
1307
|
|
|
1308
|
+
/**
|
|
1309
|
+
* Validates that every scorer an agent asks to be graded by exists.
|
|
1310
|
+
*
|
|
1311
|
+
* The runtime warns and skips an unresolvable name rather than failing a run
|
|
1312
|
+
* that has already answered the user — which means a typo would otherwise cost
|
|
1313
|
+
* nothing visible and simply grade nothing, forever.
|
|
1314
|
+
*/
|
|
1315
|
+
export function validateAgentScorerReferences(
|
|
1316
|
+
logger: InspectorLogger,
|
|
1317
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
1318
|
+
): void {
|
|
1319
|
+
const known = Object.keys(state.scorers.scorersMeta)
|
|
1320
|
+
|
|
1321
|
+
for (const [agentKey, agent] of Object.entries(state.agents.agentsMeta)) {
|
|
1322
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : ''
|
|
1323
|
+
for (const scorer of agent.scorers ?? []) {
|
|
1324
|
+
if (known.includes(scorer)) continue
|
|
1325
|
+
logger.critical(
|
|
1326
|
+
ErrorCode.AGENT_SCORER_NOT_FOUND,
|
|
1327
|
+
`AI agent '${agentKey}'${where} asks to be graded by scorer '${scorer}', which is not declared in this project. ` +
|
|
1328
|
+
`Declared scorers: ${known.join(', ') || 'none'}.`
|
|
1329
|
+
)
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1287
1334
|
/**
|
|
1288
1335
|
* Validates that every scope granted by a system role is declared via
|
|
1289
1336
|
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
@@ -170,6 +170,10 @@ export interface SerializableInspectorState {
|
|
|
170
170
|
secretOverrides?: Record<string, string>
|
|
171
171
|
variableOverrides?: Record<string, string>
|
|
172
172
|
credentialOverrides?: Record<string, string>
|
|
173
|
+
secretGrants?: string[]
|
|
174
|
+
credentialGrants?: string[]
|
|
175
|
+
globalSecrets?: string
|
|
176
|
+
globalCredentials?: string
|
|
173
177
|
},
|
|
174
178
|
]
|
|
175
179
|
>
|
|
@@ -185,6 +189,10 @@ export interface SerializableInspectorState {
|
|
|
185
189
|
agentsMeta: InspectorState['agents']['agentsMeta']
|
|
186
190
|
files: [string, { path: string; exportedName: string }][]
|
|
187
191
|
}
|
|
192
|
+
scorers: {
|
|
193
|
+
scorersMeta: InspectorState['scorers']['scorersMeta']
|
|
194
|
+
files: [string, { path: string; exportedName: string }][]
|
|
195
|
+
}
|
|
188
196
|
cli: {
|
|
189
197
|
meta: InspectorState['cli']['meta']
|
|
190
198
|
files: string[]
|
|
@@ -409,6 +417,10 @@ export function serializeInspectorState(
|
|
|
409
417
|
agentsMeta: state.agents?.agentsMeta ?? {},
|
|
410
418
|
files: Array.from(state.agents?.files?.entries() ?? []),
|
|
411
419
|
},
|
|
420
|
+
scorers: {
|
|
421
|
+
scorersMeta: state.scorers?.scorersMeta ?? {},
|
|
422
|
+
files: Array.from(state.scorers?.files?.entries() ?? []),
|
|
423
|
+
},
|
|
412
424
|
cli: {
|
|
413
425
|
meta: state.cli.meta,
|
|
414
426
|
files: Array.from(state.cli.files),
|
|
@@ -613,6 +625,10 @@ export function deserializeInspectorState(
|
|
|
613
625
|
agentsMeta: data.agents?.agentsMeta || {},
|
|
614
626
|
files: new Map(data.agents?.files || []),
|
|
615
627
|
},
|
|
628
|
+
scorers: {
|
|
629
|
+
scorersMeta: data.scorers?.scorersMeta || {},
|
|
630
|
+
files: new Map(data.scorers?.files || []),
|
|
631
|
+
},
|
|
616
632
|
cli: {
|
|
617
633
|
meta: data.cli.meta,
|
|
618
634
|
files: new Set(data.cli.files),
|