@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/dist/add/add-ai-agent.js +25 -0
  3. package/dist/add/add-ai-scorer.d.ts +2 -0
  4. package/dist/add/add-ai-scorer.js +61 -0
  5. package/dist/add/add-wire-addon.js +26 -0
  6. package/dist/add/scenario-instrumentation.d.ts +6 -1
  7. package/dist/add/scenario-instrumentation.js +6 -0
  8. package/dist/error-codes.d.ts +2 -0
  9. package/dist/error-codes.js +3 -0
  10. package/dist/inspector.js +7 -3
  11. package/dist/types.d.ts +24 -0
  12. package/dist/utils/filter-inspector-state.js +8 -0
  13. package/dist/utils/post-process.d.ts +13 -3
  14. package/dist/utils/post-process.js +51 -21
  15. package/dist/utils/serialize-inspector-state.d.ts +11 -0
  16. package/dist/utils/serialize-inspector-state.js +8 -0
  17. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +13 -4
  18. package/dist/utils/workflow/dsl/patterns.d.ts +2 -1
  19. package/dist/utils/workflow/dsl/patterns.js +8 -5
  20. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +2 -1
  21. package/dist/visit.js +2 -0
  22. package/package.json +3 -3
  23. package/src/add/add-ai-agent.ts +35 -0
  24. package/src/add/add-ai-scorer.test.ts +197 -0
  25. package/src/add/add-ai-scorer.ts +83 -0
  26. package/src/add/add-functions.ts +1 -1
  27. package/src/add/add-scenario-step.test.ts +21 -1
  28. package/src/add/add-wire-addon.test.ts +74 -0
  29. package/src/add/add-wire-addon.ts +22 -0
  30. package/src/add/scenario-instrumentation.ts +6 -0
  31. package/src/error-codes.ts +4 -0
  32. package/src/inspector.ts +7 -3
  33. package/src/types.ts +21 -0
  34. package/src/utils/filter-inspector-state.ts +8 -0
  35. package/src/utils/post-process.test.ts +138 -0
  36. package/src/utils/post-process.ts +69 -22
  37. package/src/utils/serialize-inspector-state.ts +16 -0
  38. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +68 -0
  39. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +13 -5
  40. package/src/utils/workflow/dsl/patterns.ts +8 -5
  41. package/src/utils/workflow/graph/workflow-graph.types.ts +1 -1
  42. package/src/visit.ts +2 -0
  43. package/tsconfig.tsbuildinfo +1 -1
  44. package/src/utils/secret-brokers.test.ts +0 -53
  45. 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 { ScenarioStepMeta, WorkflowStepMeta } from '@pikku/core/workflow'
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 =
@@ -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
- if (!model.includes('/')) {
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 must be provider-qualified as '<provider>/<model>' (e.g. 'openai/gpt-4').`
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),