@pikku/inspector 0.12.50 → 0.12.54

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 (116) hide show
  1. package/CHANGELOG.md +743 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +199 -8
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +216 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/add/add-workflow.js +12 -1
  19. package/dist/error-codes.d.ts +17 -1
  20. package/dist/error-codes.js +23 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/inspector.js +33 -1
  23. package/dist/types.d.ts +55 -2
  24. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  25. package/dist/utils/annotate-http-route-auth.js +48 -0
  26. package/dist/utils/check-secret-sinks.d.ts +31 -0
  27. package/dist/utils/check-secret-sinks.js +117 -0
  28. package/dist/utils/extract-function-name.js +13 -4
  29. package/dist/utils/extract-function-node.js +18 -1
  30. package/dist/utils/extract-secret-usage.d.ts +9 -0
  31. package/dist/utils/extract-secret-usage.js +61 -0
  32. package/dist/utils/literal-properties.d.ts +22 -0
  33. package/dist/utils/literal-properties.js +69 -0
  34. package/dist/utils/load-addon-functions-meta.js +26 -1
  35. package/dist/utils/permissions.js +8 -0
  36. package/dist/utils/post-process.d.ts +68 -3
  37. package/dist/utils/post-process.js +246 -13
  38. package/dist/utils/secret-alias-services.d.ts +8 -0
  39. package/dist/utils/secret-alias-services.js +45 -0
  40. package/dist/utils/secret-brokers.d.ts +2 -0
  41. package/dist/utils/secret-brokers.js +9 -0
  42. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  43. package/dist/utils/serialize-inspector-state.js +18 -0
  44. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  45. package/dist/utils/single-declaration.d.ts +32 -0
  46. package/dist/utils/single-declaration.js +43 -0
  47. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  48. package/dist/utils/validate-exposed-functions-gated.js +93 -0
  49. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  50. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  51. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
  52. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
  53. package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
  54. package/dist/utils/workflow/dsl/patterns.js +26 -4
  55. package/dist/visit.js +4 -0
  56. package/package.json +3 -3
  57. package/src/add/add-auth.test.ts +5 -3
  58. package/src/add/add-auth.ts +1 -1
  59. package/src/add/add-cli.ts +8 -0
  60. package/src/add/add-credential.ts +1 -1
  61. package/src/add/add-functions.test.ts +263 -1
  62. package/src/add/add-functions.ts +273 -11
  63. package/src/add/add-http-route.ts +20 -7
  64. package/src/add/add-middleware.test.ts +196 -0
  65. package/src/add/add-middleware.ts +64 -4
  66. package/src/add/add-personas.test.ts +382 -0
  67. package/src/add/add-personas.ts +285 -0
  68. package/src/add/add-scenario-step.test.ts +87 -5
  69. package/src/add/add-scope.test.ts +103 -30
  70. package/src/add/add-scope.ts +1 -1
  71. package/src/add/add-secret.ts +1 -1
  72. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  73. package/src/add/add-system-role.test.ts +297 -0
  74. package/src/add/add-system-role.ts +164 -0
  75. package/src/add/add-variable.ts +1 -1
  76. package/src/add/add-wire-addon.test.ts +111 -0
  77. package/src/add/add-wire-addon.ts +29 -0
  78. package/src/add/add-workflow.ts +21 -1
  79. package/src/add/addon-bans.test.ts +4 -4
  80. package/src/add/allow-escape-hatches.test.ts +144 -0
  81. package/src/add/audit-meta.test.ts +75 -0
  82. package/src/add/config-value-metadata.test.ts +11 -11
  83. package/src/add/secret-sink-check.test.ts +211 -0
  84. package/src/add/wire-name-literal.test.ts +2 -2
  85. package/src/error-codes.ts +26 -0
  86. package/src/index.ts +4 -1
  87. package/src/inspector.ts +39 -0
  88. package/src/types.ts +56 -2
  89. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  90. package/src/utils/annotate-http-route-auth.ts +52 -0
  91. package/src/utils/check-secret-sinks.ts +145 -0
  92. package/src/utils/extract-function-name.ts +14 -4
  93. package/src/utils/extract-function-node.ts +18 -6
  94. package/src/utils/extract-secret-usage.test.ts +81 -0
  95. package/src/utils/extract-secret-usage.ts +73 -0
  96. package/src/utils/literal-properties.ts +97 -0
  97. package/src/utils/load-addon-functions-meta.ts +32 -2
  98. package/src/utils/permissions-shorthand.test.ts +79 -0
  99. package/src/utils/permissions.ts +7 -0
  100. package/src/utils/post-process.test.ts +133 -0
  101. package/src/utils/post-process.ts +313 -12
  102. package/src/utils/secret-alias-services.test.ts +146 -0
  103. package/src/utils/secret-alias-services.ts +58 -0
  104. package/src/utils/secret-brokers.test.ts +53 -0
  105. package/src/utils/secret-brokers.ts +11 -0
  106. package/src/utils/serialize-inspector-state.ts +28 -0
  107. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  108. package/src/utils/single-declaration.ts +57 -0
  109. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  110. package/src/utils/validate-exposed-functions-gated.ts +103 -0
  111. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  112. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  113. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
  114. package/src/utils/workflow/dsl/patterns.ts +33 -4
  115. package/src/visit.ts +4 -0
  116. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,81 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import ts from 'typescript'
4
+ import { extractSecretUsage } from './extract-secret-usage.js'
5
+
6
+ const usageOf = (source: string) =>
7
+ extractSecretUsage(
8
+ ts.createSourceFile('/services.ts', source, ts.ScriptTarget.ESNext, true)
9
+ )
10
+
11
+ describe('extractSecretUsage', () => {
12
+ test('collects a literal getSecret key', () => {
13
+ const usage = usageOf(`await secrets.getSecret('STRIPE_KEY')`)
14
+ assert.deepEqual(usage.keys, ['STRIPE_KEY'])
15
+ assert.deepEqual(usage.dynamic, [])
16
+ })
17
+
18
+ test('collects hasSecret and template literals with no substitutions', () => {
19
+ const usage = usageOf(
20
+ 'await secrets.hasSecret(`NOTION_TOKEN`)\nawait secrets.getSecret("A")'
21
+ )
22
+ assert.deepEqual(usage.keys, ['A', 'NOTION_TOKEN'])
23
+ })
24
+
25
+ test('collects every key of a getSecrets array', () => {
26
+ const usage = usageOf(`await secrets.getSecrets(['B', 'A'])`)
27
+ assert.deepEqual(usage.keys, ['A', 'B'])
28
+ })
29
+
30
+ test('flags a computed key as dynamic', () => {
31
+ const usage = usageOf(`await secrets.getSecret(name)`)
32
+ assert.deepEqual(usage.keys, [])
33
+ assert.equal(usage.dynamic.length, 1)
34
+ assert.match(usage.dynamic[0]!, /^1:name$/)
35
+ })
36
+
37
+ test('flags a template literal with a substitution as dynamic', () => {
38
+ const usage = usageOf('await secrets.getSecret(`PREFIX_${suffix}`)')
39
+ assert.deepEqual(usage.keys, [])
40
+ assert.equal(usage.dynamic.length, 1)
41
+ })
42
+
43
+ test('flags a non-array getSecrets argument as dynamic', () => {
44
+ const usage = usageOf(`await secrets.getSecrets(keys)`)
45
+ assert.equal(usage.dynamic.length, 1)
46
+ })
47
+
48
+ test('records both the literal and the dynamic keys of one call', () => {
49
+ const usage = usageOf(`await secrets.getSecrets(['A', other])`)
50
+ assert.deepEqual(usage.keys, ['A'])
51
+ assert.equal(usage.dynamic.length, 1)
52
+ })
53
+
54
+ test('finds reads nested inside a factory body', () => {
55
+ const usage = usageOf(`
56
+ export const createSingletonServices = async ({ secrets }) => {
57
+ const key = await secrets.getSecret('DEEP_KEY')
58
+ return { thing: new Thing(key) }
59
+ }
60
+ `)
61
+ assert.deepEqual(usage.keys, ['DEEP_KEY'])
62
+ })
63
+
64
+ test('deduplicates a key read twice', () => {
65
+ const usage = usageOf(
66
+ `await secrets.getSecret('A'); await secrets.getSecret('A')`
67
+ )
68
+ assert.deepEqual(usage.keys, ['A'])
69
+ })
70
+
71
+ test('ignores unrelated calls', () => {
72
+ const usage = usageOf(`await variables.get('HOME'); logger.info('hi')`)
73
+ assert.deepEqual(usage.keys, [])
74
+ assert.deepEqual(usage.dynamic, [])
75
+ })
76
+
77
+ test('reports the line a dynamic read is on', () => {
78
+ const usage = usageOf(`const a = 1\nconst b = 2\nsecrets.getSecret(name)`)
79
+ assert.match(usage.dynamic[0]!, /^3:/)
80
+ })
81
+ })
@@ -0,0 +1,73 @@
1
+ import ts from 'typescript'
2
+
3
+ export type SecretUsage = {
4
+ /** Secret ids read with a string literal, so statically known. */
5
+ keys: string[]
6
+ /** Reads whose key could not be resolved statically, as `line:source`. */
7
+ dynamic: string[]
8
+ }
9
+
10
+ const SINGLE_KEY_METHODS = new Set(['getSecret', 'hasSecret'])
11
+ const KEY_LIST_METHODS = new Set(['getSecrets'])
12
+
13
+ const literalKey = (node: ts.Expression): string | undefined => {
14
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
15
+ return node.text
16
+ }
17
+ return undefined
18
+ }
19
+
20
+ /** Secret reads in a file, whether the key is statically known or not. */
21
+ export const extractSecretUsage = (sourceFile: ts.SourceFile): SecretUsage => {
22
+ const keys = new Set<string>()
23
+ const dynamic = new Set<string>()
24
+
25
+ const record = (node: ts.CallExpression, argument?: ts.Expression) => {
26
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
27
+ dynamic.add(`${line + 1}:${(argument ?? node).getText()}`)
28
+ }
29
+
30
+ const visit = (node: ts.Node): void => {
31
+ if (
32
+ ts.isCallExpression(node) &&
33
+ ts.isPropertyAccessExpression(node.expression)
34
+ ) {
35
+ const method = node.expression.name.text
36
+ const [firstArgument] = node.arguments
37
+
38
+ if (SINGLE_KEY_METHODS.has(method)) {
39
+ if (!firstArgument) {
40
+ record(node)
41
+ } else {
42
+ const key = literalKey(firstArgument)
43
+ if (key === undefined) {
44
+ record(node, firstArgument)
45
+ } else {
46
+ keys.add(key)
47
+ }
48
+ }
49
+ } else if (KEY_LIST_METHODS.has(method)) {
50
+ if (firstArgument && ts.isArrayLiteralExpression(firstArgument)) {
51
+ for (const element of firstArgument.elements) {
52
+ const key = literalKey(element)
53
+ if (key === undefined) {
54
+ record(node, element)
55
+ } else {
56
+ keys.add(key)
57
+ }
58
+ }
59
+ } else {
60
+ record(node, firstArgument)
61
+ }
62
+ }
63
+ }
64
+ ts.forEachChild(node, visit)
65
+ }
66
+
67
+ visit(sourceFile)
68
+
69
+ return {
70
+ keys: [...keys].sort(),
71
+ dynamic: [...dynamic].sort(),
72
+ }
73
+ }
@@ -0,0 +1,97 @@
1
+ import * as ts from 'typescript'
2
+
3
+ /**
4
+ * Readers for object-literal properties that must be statically knowable.
5
+ *
6
+ * Declarations extracted this way are never evaluated — the CLI reads them from
7
+ * source and writes them to JSON that a deployed stage carries without the app.
8
+ * So a computed value is not "unsupported", it is unreadable, and every reader
9
+ * here returns `undefined` for one rather than guessing.
10
+ */
11
+
12
+ export const getProperty = (
13
+ config: ts.ObjectLiteralExpression,
14
+ name: string
15
+ ): ts.Expression | undefined => {
16
+ const property = config.properties.find(
17
+ (p) =>
18
+ ts.isPropertyAssignment(p) &&
19
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
20
+ p.name.text === name
21
+ )
22
+ return property && ts.isPropertyAssignment(property)
23
+ ? property.initializer
24
+ : undefined
25
+ }
26
+
27
+ export const stringProperty = (
28
+ config: ts.ObjectLiteralExpression,
29
+ name: string
30
+ ): string | undefined => {
31
+ const value = getProperty(config, name)
32
+ return value && ts.isStringLiteralLike(value) ? value.text : undefined
33
+ }
34
+
35
+ export const numberProperty = (
36
+ config: ts.ObjectLiteralExpression,
37
+ name: string
38
+ ): number | undefined => {
39
+ const value = getProperty(config, name)
40
+ if (!value) {
41
+ return undefined
42
+ }
43
+ // `-1` parses as a unary minus over a literal, not as a literal. Reading it
44
+ // rather than ignoring it is what lets a negative be *rejected* by the caller
45
+ // instead of silently dropped and defaulted.
46
+ if (
47
+ ts.isPrefixUnaryExpression(value) &&
48
+ value.operator === ts.SyntaxKind.MinusToken &&
49
+ ts.isNumericLiteral(value.operand)
50
+ ) {
51
+ return -Number(value.operand.text)
52
+ }
53
+ return ts.isNumericLiteral(value) ? Number(value.text) : undefined
54
+ }
55
+
56
+ export const booleanProperty = (
57
+ config: ts.ObjectLiteralExpression,
58
+ name: string
59
+ ): boolean | undefined => {
60
+ const value = getProperty(config, name)
61
+ if (!value) {
62
+ return undefined
63
+ }
64
+ if (value.kind === ts.SyntaxKind.TrueKeyword) {
65
+ return true
66
+ }
67
+ if (value.kind === ts.SyntaxKind.FalseKeyword) {
68
+ return false
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ /**
74
+ * All-or-nothing: an array with one computed entry reads as unreadable rather
75
+ * than as the entries that happened to be literals. A half-read `roles` or
76
+ * `scopes` array is worse than none — it typechecks, runs, and grants less
77
+ * than the source says.
78
+ */
79
+ export const stringArrayProperty = (
80
+ config: ts.ObjectLiteralExpression,
81
+ name: string
82
+ ): string[] | undefined => {
83
+ const value = getProperty(config, name)
84
+ if (!value || !ts.isArrayLiteralExpression(value)) {
85
+ return undefined
86
+ }
87
+ const values = value.elements
88
+ .filter(ts.isStringLiteralLike)
89
+ .map((e) => e.text)
90
+ return values.length === value.elements.length ? values : undefined
91
+ }
92
+
93
+ /** Unwraps `x as const` / `x satisfies T` so a cast is still extracted. */
94
+ export const unwrapAs = (node: ts.Expression): ts.Expression =>
95
+ ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
96
+ ? unwrapAs(node.expression)
97
+ : node
@@ -118,6 +118,29 @@ const applyPackageToChannelContracts = (
118
118
  }
119
119
  }
120
120
 
121
+ /**
122
+ * Resolves an addon's generated metadata, preferring the verbose copy.
123
+ *
124
+ * An addon publishes both: the minimal one its own bootstrap imports, and the
125
+ * verbose one for anything that needs the fields stripped out of it. A consumer
126
+ * is squarely in the second category — it is reading the addon to describe it,
127
+ * not to run it.
128
+ */
129
+ const resolveAddonMeta = (
130
+ require: NodeRequire,
131
+ packageName: string,
132
+ baseName: string
133
+ ): string | null => {
134
+ for (const suffix of ['-verbose.gen.json', '.gen.json']) {
135
+ try {
136
+ return require.resolve(`${packageName}/.pikku/${baseName}${suffix}`)
137
+ } catch {
138
+ continue
139
+ }
140
+ }
141
+ return null
142
+ }
143
+
121
144
  /**
122
145
  * After the setup sweep discovers wireAddon() declarations, load each addon
123
146
  * package's function metadata so that wiring handlers (channels, HTTP routes,
@@ -138,9 +161,16 @@ export async function loadAddonFunctionsMeta(
138
161
  // HOST that runs them — never load or require any of that here.
139
162
  if (decl.remote) continue
140
163
  try {
141
- const metaPath = require.resolve(
142
- `${decl.package}/.pikku/function/pikku-functions-meta.gen.json`
164
+ // Verbose first. `description` is one of the fields stripped from the
165
+ // minimal copy, and it is what an addon's function is offered to a model
166
+ // under — both as an MCP tool below and as an agent tool. Read the minimal
167
+ // one and every addon function looks undescribed.
168
+ const metaPath = resolveAddonMeta(
169
+ require,
170
+ decl.package,
171
+ 'function/pikku-functions-meta'
143
172
  )
173
+ if (!metaPath) throw new Error('no function metadata')
144
174
  const raw = await readFile(metaPath, 'utf-8')
145
175
  const meta = JSON.parse(raw)
146
176
  state.addonFunctions[namespace] = meta
@@ -0,0 +1,79 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import * as ts from 'typescript'
4
+ import { extractPermissionPikkuNames } from './permissions.js'
5
+
6
+ /**
7
+ * A checker stub. With no symbols to resolve, every reference falls back to a
8
+ * deterministic `__temp_` id — which is fine, because the question here is
9
+ * whether a declared permission is *seen*, not what it resolves to. A dropped
10
+ * property yields nothing at all, and that is the bug being pinned.
11
+ */
12
+ const checker = {
13
+ getSymbolAtLocation: () => undefined,
14
+ getAliasedSymbol: () => undefined,
15
+ getShorthandAssignmentValueSymbol: () => undefined,
16
+ } as unknown as ts.TypeChecker
17
+
18
+ /** Parses `permissions: <expr>` and counts the references the inspector records. */
19
+ const countFor = (permissionsExpression: string): number => {
20
+ const file = ts.createSourceFile(
21
+ 'fn.ts',
22
+ `const config = { permissions: ${permissionsExpression} }`,
23
+ ts.ScriptTarget.Latest,
24
+ true
25
+ )
26
+ let node: ts.Expression | undefined
27
+ const visit = (n: ts.Node) => {
28
+ if (
29
+ ts.isPropertyAssignment(n) &&
30
+ ts.isIdentifier(n.name) &&
31
+ n.name.text === 'permissions'
32
+ ) {
33
+ node = n.initializer
34
+ }
35
+ ts.forEachChild(n, visit)
36
+ }
37
+ visit(file)
38
+ assert.ok(node, 'failed to find the permissions node')
39
+ return extractPermissionPikkuNames(node, checker, '/root').length
40
+ }
41
+
42
+ describe('extractPermissionPikkuNames', () => {
43
+ test('records a permission declared with object shorthand', () => {
44
+ // `permissions: { canAdminOrg }` is enforced identically to the longhand
45
+ // form at runtime — verifyPermissions has a non-array branch — but was
46
+ // dropped here, so meta reported a guarded function as carrying no
47
+ // permissions at all. An audit reading meta saw an open door where one
48
+ // was shut.
49
+ assert.equal(countFor('{ canAdminOrg }'), 1)
50
+ })
51
+
52
+ test('records the longhand form', () => {
53
+ assert.equal(countFor('{ org: canAdminOrg }'), 1)
54
+ })
55
+
56
+ test('records shorthand and longhand together', () => {
57
+ assert.equal(countFor('{ canAdminOrg, billing: canBill }'), 2)
58
+ })
59
+
60
+ test('records several shorthands', () => {
61
+ assert.equal(countFor('{ canAdminOrg, canBill }'), 2)
62
+ })
63
+
64
+ test('records the array form', () => {
65
+ assert.equal(countFor('[canAdminOrg, canBill]'), 2)
66
+ })
67
+
68
+ test('records a group whose value is an array', () => {
69
+ assert.equal(countFor('{ org: [canAdminOrg, canBill] }'), 2)
70
+ })
71
+
72
+ test('records a call expression, shorthand-adjacent', () => {
73
+ assert.equal(countFor('{ quota: hasEmailQuota(100) }'), 1)
74
+ })
75
+
76
+ test('records nothing for an empty object', () => {
77
+ assert.equal(countFor('{}'), 0)
78
+ })
79
+ })
@@ -51,6 +51,13 @@ export function extractPermissionPikkuNames(
51
51
  for (const prop of node.properties) {
52
52
  if (ts.isPropertyAssignment(prop)) {
53
53
  extractFromElement(prop.initializer)
54
+ } else if (ts.isShorthandPropertyAssignment(prop)) {
55
+ // `permissions: { canAdminOrg }`. The runtime resolves this exactly
56
+ // like the longhand form — `verifyPermissions` has a non-array branch —
57
+ // so dropping it here reported a guarded function as having no
58
+ // permissions at all, which is the most dangerous direction to be wrong
59
+ // in: an audit reading meta sees an open door where one is shut.
60
+ extractFromElement(prop.name)
54
61
  }
55
62
  }
56
63
  }
@@ -10,6 +10,7 @@ import {
10
10
  validateVariableOverrides,
11
11
  validateRemoteAddonDependencies,
12
12
  validateRemoteAddonAuth,
13
+ validateAgentToolReferences,
13
14
  } from './post-process.js'
14
15
  import { ErrorCode } from '../error-codes.js'
15
16
  import type { InspectorState, InspectorLogger } from '../types.js'
@@ -580,3 +581,135 @@ describe('validateRemoteAddonAuth (bound credential/secret must exist)', () => {
580
581
  assert.equal(criticals[0]!.code, ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED)
581
582
  })
582
583
  })
584
+
585
+ const makeAgentState = (
586
+ tools: string[],
587
+ {
588
+ functions = {},
589
+ addonFunctions = {},
590
+ addons = [],
591
+ internalMeta = {},
592
+ }: {
593
+ functions?: Record<string, any>
594
+ addonFunctions?: Record<string, Record<string, any>>
595
+ addons?: string[]
596
+ internalMeta?: Record<string, string>
597
+ } = {}
598
+ ): Omit<InspectorState, 'typesLookup'> =>
599
+ ({
600
+ agents: {
601
+ agentsMeta: {
602
+ triage: { tools, sourceFile: 'src/triage.agent.ts' },
603
+ },
604
+ },
605
+ rpc: {
606
+ internalMeta,
607
+ wireAddonDeclarations: new Map(
608
+ addons.map((name) => [name, { package: `@addon/${name}` }])
609
+ ),
610
+ },
611
+ functions: { meta: functions },
612
+ addonFunctions,
613
+ }) as unknown as Omit<InspectorState, 'typesLookup'>
614
+
615
+ describe('validateAgentToolReferences (ref() resolved at build time)', () => {
616
+ test('a local tool that does not exist is an error', () => {
617
+ const { logger, criticals } = makeCriticalLogger()
618
+ validateAgentToolReferences(
619
+ logger,
620
+ makeAgentState(['doesNotExist'], {
621
+ functions: { realTool: { description: 'A real one' } },
622
+ })
623
+ )
624
+ assert.equal(criticals.length, 1)
625
+ assert.equal(criticals[0]!.code, ErrorCode.AGENT_TOOL_NOT_FOUND)
626
+ })
627
+
628
+ test('an addon namespace that is not wired is an error, and lists the ones that are', () => {
629
+ const { logger, criticals } = makeCriticalLogger()
630
+ validateAgentToolReferences(
631
+ logger,
632
+ makeAgentState(['nope:thing'], { addons: ['todos'] })
633
+ )
634
+ assert.equal(criticals.length, 1)
635
+ assert.equal(criticals[0]!.code, ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE)
636
+ assert.match(criticals[0]!.message, /Wired namespaces: todos/)
637
+ })
638
+
639
+ test('a wired addon that does not export the function is an error', () => {
640
+ const { logger, criticals } = makeCriticalLogger()
641
+ validateAgentToolReferences(
642
+ logger,
643
+ makeAgentState(['todos:noSuchFn'], {
644
+ addons: ['todos'],
645
+ addonFunctions: { todos: { addTodo: { description: 'Adds a todo' } } },
646
+ })
647
+ )
648
+ assert.equal(criticals.length, 1)
649
+ assert.equal(criticals[0]!.code, ErrorCode.AGENT_TOOL_NOT_FOUND)
650
+ })
651
+
652
+ test('an addon that has not been built yet is not treated as a missing function', () => {
653
+ const { logger, criticals } = makeCriticalLogger()
654
+ validateAgentToolReferences(
655
+ logger,
656
+ makeAgentState(['todos:addTodo'], { addons: ['todos'] })
657
+ )
658
+ assert.deepEqual(criticals, [])
659
+ })
660
+
661
+ test('a workflow reference is resolved elsewhere and left alone', () => {
662
+ const { logger, criticals } = makeCriticalLogger()
663
+ validateAgentToolReferences(
664
+ logger,
665
+ makeAgentState(['workflow:onboarding']),
666
+ { strictMeta: true }
667
+ )
668
+ assert.deepEqual(criticals, [])
669
+ })
670
+
671
+ test('a missing description is only an error under strictMeta', () => {
672
+ const state = makeAgentState(['bareTool'], {
673
+ functions: { bareTool: {} },
674
+ })
675
+
676
+ const lenient = makeCriticalLogger()
677
+ validateAgentToolReferences(lenient.logger, state)
678
+ assert.deepEqual(lenient.criticals, [])
679
+
680
+ const strict = makeCriticalLogger()
681
+ validateAgentToolReferences(strict.logger, state, { strictMeta: true })
682
+ assert.equal(strict.criticals.length, 1)
683
+ assert.equal(
684
+ strict.criticals[0]!.code,
685
+ ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION
686
+ )
687
+ })
688
+
689
+ test('a title does not satisfy strictMeta, and the message says so', () => {
690
+ const { logger, criticals } = makeCriticalLogger()
691
+ validateAgentToolReferences(
692
+ logger,
693
+ makeAgentState(['titledTool'], {
694
+ functions: { titledTool: { title: 'Titled Tool' } },
695
+ }),
696
+ { strictMeta: true }
697
+ )
698
+ assert.equal(criticals.length, 1)
699
+ assert.equal(criticals[0]!.code, ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION)
700
+ assert.match(criticals[0]!.message, /a 'title' does not count/)
701
+ })
702
+
703
+ test('an addon function is checked against the addon’s own meta', () => {
704
+ const { logger, criticals } = makeCriticalLogger()
705
+ validateAgentToolReferences(
706
+ logger,
707
+ makeAgentState(['todos:addTodo'], {
708
+ addons: ['todos'],
709
+ addonFunctions: { todos: { addTodo: { description: 'Adds a todo' } } },
710
+ }),
711
+ { strictMeta: true }
712
+ )
713
+ assert.deepEqual(criticals, [])
714
+ })
715
+ })