melchizedek-agents 1.1.0 → 1.3.0

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 (146) hide show
  1. package/.env.example +19 -0
  2. package/AGENT_SETUP.md +3 -1
  3. package/CHANGELOG.md +373 -0
  4. package/DOCUMENTATION.md +193 -21
  5. package/ONBOARDING.md +378 -0
  6. package/QUICKSTART.md +15 -2
  7. package/README.md +26 -3
  8. package/config/agents/examples/connectors.yaml +82 -0
  9. package/config/agents/examples/council.yaml +1 -1
  10. package/config/agents/examples/critic.yaml +8 -8
  11. package/config/agents/examples/image_production.yaml +4 -3
  12. package/config/agents/syndicate.schema.json +389 -33
  13. package/config/agents/syndicateSchema.yaml +18 -6
  14. package/config/agents/templates/intake_extractor.yaml +3 -2
  15. package/config/agents/templates/systems_operator.yaml +5 -2
  16. package/dist/lib/a2a/app.d.ts +12 -0
  17. package/dist/lib/a2a/app.js +25 -11
  18. package/dist/lib/a2a/executor.js +14 -6
  19. package/dist/lib/a2a/oauthSetup.d.ts +16 -0
  20. package/dist/lib/a2a/oauthSetup.js +77 -8
  21. package/dist/lib/agentDialect.d.ts +18 -8
  22. package/dist/lib/agentDialect.js +36 -19
  23. package/dist/lib/chatgpt/adapter.d.ts +50 -0
  24. package/dist/lib/chatgpt/adapter.js +107 -0
  25. package/dist/lib/chatgpt/cli.d.ts +23 -0
  26. package/dist/lib/chatgpt/cli.js +77 -0
  27. package/dist/lib/chatgpt/oauth.d.ts +103 -0
  28. package/dist/lib/chatgpt/oauth.js +448 -0
  29. package/dist/lib/chatgpt/state.d.ts +98 -0
  30. package/dist/lib/chatgpt/state.js +145 -0
  31. package/dist/lib/chatgpt/store.d.ts +24 -0
  32. package/dist/lib/chatgpt/store.js +94 -0
  33. package/dist/lib/compile.d.ts +38 -7
  34. package/dist/lib/compile.js +97 -62
  35. package/dist/lib/compileNative.d.ts +11 -3
  36. package/dist/lib/compileNative.js +18 -1
  37. package/dist/lib/dispatch.js +6 -2
  38. package/dist/lib/doctor.d.ts +107 -0
  39. package/dist/lib/doctor.js +217 -7
  40. package/dist/lib/loadSyndicate.d.ts +15 -0
  41. package/dist/lib/mcp/server.d.ts +195 -0
  42. package/dist/lib/mcp/server.js +564 -0
  43. package/dist/lib/memory/supabaseMemoryService.js +2 -2
  44. package/dist/lib/models/adapterResolver.js +6 -1
  45. package/dist/lib/models/capabilities.d.ts +9 -0
  46. package/dist/lib/models/capabilities.js +24 -0
  47. package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
  48. package/dist/lib/models/chatCompletionsAdapter.js +19 -5
  49. package/dist/lib/models/claudeAdapter.js +4 -0
  50. package/dist/lib/models/contract.d.ts +5 -2
  51. package/dist/lib/models/gateway.d.ts +3 -2
  52. package/dist/lib/models/gateway.js +8 -4
  53. package/dist/lib/models/gatewayAdapter.d.ts +7 -1
  54. package/dist/lib/models/gatewayAdapter.js +8 -0
  55. package/dist/lib/models/geminiAdapter.js +8 -2
  56. package/dist/lib/models/genaiMapping.d.ts +8 -4
  57. package/dist/lib/models/genaiMapping.js +23 -9
  58. package/dist/lib/models/gptAdapter.d.ts +4 -2
  59. package/dist/lib/models/gptAdapter.js +11 -4
  60. package/dist/lib/models/grokAdapter.d.ts +3 -1
  61. package/dist/lib/models/grokAdapter.js +11 -3
  62. package/dist/lib/models/kimiAdapter.d.ts +2 -1
  63. package/dist/lib/models/kimiAdapter.js +3 -3
  64. package/dist/lib/models/reasoning.d.ts +30 -2
  65. package/dist/lib/models/reasoning.js +56 -5
  66. package/dist/lib/onboarding.d.ts +135 -0
  67. package/dist/lib/onboarding.js +578 -0
  68. package/dist/lib/runtime/approvals.d.ts +2 -0
  69. package/dist/lib/runtime/approvals.js +4 -3
  70. package/dist/lib/runtime/credentials.d.ts +6 -0
  71. package/dist/lib/runtime/native/agentLoop.d.ts +5 -2
  72. package/dist/lib/runtime/native/agentLoop.js +2 -0
  73. package/dist/lib/runtime/native/checkpoint.d.ts +12 -5
  74. package/dist/lib/runtime/native/checkpoint.js +13 -19
  75. package/dist/lib/runtime/native/delegate.d.ts +82 -1
  76. package/dist/lib/runtime/native/delegate.js +157 -2
  77. package/dist/lib/runtime/native/interrupts.d.ts +88 -5
  78. package/dist/lib/runtime/native/interrupts.js +220 -30
  79. package/dist/lib/runtime/syndicateTurn.d.ts +4 -2
  80. package/dist/lib/runtime/syndicateTurn.js +324 -77
  81. package/dist/lib/runtime/turnControl.d.ts +41 -0
  82. package/dist/lib/session/transcript.d.ts +9 -0
  83. package/dist/lib/session/transcript.js +20 -1
  84. package/dist/lib/skills.d.ts +18 -0
  85. package/dist/lib/skills.js +73 -1
  86. package/dist/lib/storage/postgres/index.d.ts +6 -1
  87. package/dist/lib/storage/postgres/index.js +1 -1
  88. package/dist/lib/storage/postgres/taskQueue.d.ts +7 -3
  89. package/dist/lib/storage/postgres/taskQueue.js +10 -3
  90. package/dist/lib/syndicateSchema.d.ts +184 -9
  91. package/dist/lib/syndicateSchema.js +143 -29
  92. package/dist/lib/tools/auth.d.ts +13 -1
  93. package/dist/lib/tools/auth.js +10 -2
  94. package/dist/lib/tools/credentialHosts.d.ts +88 -0
  95. package/dist/lib/tools/credentialHosts.js +165 -0
  96. package/dist/lib/tools/credentialUses.d.ts +98 -0
  97. package/dist/lib/tools/credentialUses.js +120 -0
  98. package/dist/lib/tools/generateImageTool.js +2 -1
  99. package/dist/lib/tools/inspectImageTool.js +1 -1
  100. package/dist/lib/tools/mcpToolFactory.d.ts +29 -1
  101. package/dist/lib/tools/mcpToolFactory.js +72 -26
  102. package/dist/lib/tools/mcpTransports.d.ts +12 -0
  103. package/dist/lib/tools/mcpTransports.js +11 -0
  104. package/dist/lib/tools/oauthConsent.d.ts +18 -3
  105. package/dist/lib/tools/oauthConsent.js +57 -15
  106. package/dist/lib/tools/oauthDiscovery.d.ts +202 -0
  107. package/dist/lib/tools/oauthDiscovery.js +616 -0
  108. package/dist/lib/tools/oauthHosts.d.ts +2 -0
  109. package/dist/lib/tools/oauthHosts.js +2 -2
  110. package/dist/lib/tools/oauthTools.d.ts +54 -13
  111. package/dist/lib/tools/oauthTools.js +106 -23
  112. package/dist/lib/tools/openapiTools.d.ts +7 -0
  113. package/dist/lib/tools/openapiTools.js +49 -3
  114. package/dist/lib/tools/taskTools.d.ts +32 -2
  115. package/dist/lib/tools/taskTools.js +93 -33
  116. package/dist/lib/tools/xApiSearchTool.js +2 -2
  117. package/dist/lib/workflow/turn.d.ts +22 -3
  118. package/dist/lib/workflow/turn.js +151 -31
  119. package/dist/lib/workflow.d.ts +10 -7
  120. package/dist/lib/workflow.js +10 -7
  121. package/dist/scripts/a2a_server.d.ts +2 -2
  122. package/dist/scripts/a2a_server.js +19 -2
  123. package/dist/scripts/assistant_worker.d.ts +2 -0
  124. package/dist/scripts/assistant_worker.js +24 -5
  125. package/dist/scripts/doctor.d.ts +4 -2
  126. package/dist/scripts/doctor.js +6 -4
  127. package/dist/scripts/mcp_server.d.ts +12 -0
  128. package/dist/scripts/mcp_server.js +345 -0
  129. package/dist/scripts/setup.d.ts +25 -0
  130. package/dist/scripts/setup.js +181 -0
  131. package/dist/scripts/skills_install.d.ts +3 -1
  132. package/dist/scripts/skills_install.js +17 -2
  133. package/dist/scripts/syndicate_chat.js +12 -3
  134. package/dist/scripts/yaml_codemod.d.ts +12 -2
  135. package/dist/scripts/yaml_codemod.js +115 -33
  136. package/package.json +8 -2
  137. package/skills/README.md +11 -2
  138. package/skills/briefs/_shared.md +1 -1
  139. package/skills/briefs/melchizedek-models.md +4 -2
  140. package/skills/briefs/skills-readme.md +3 -1
  141. package/skills/melchizedek-models/SKILL.md +6 -2
  142. package/skills/melchizedek-onboard/SKILL.md +71 -0
  143. package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
  144. package/skills/melchizedek-onboard-keys/SKILL.md +47 -0
  145. package/skills/melchizedek-onboard-local/SKILL.md +41 -0
  146. package/skills/melchizedek-onboard-serve/SKILL.md +43 -0
@@ -26,6 +26,7 @@ import { DEFAULT_ROUTE_KEY, NODE_KINDS, ROUTE_STEP_SUFFIX, START_NAME, elementNa
26
26
  import { isSecretShapedEnvName } from './tools/skills/env.js';
27
27
  import { OAUTH_SCOPE, PROVIDER_NAME } from './tools/auth.js';
28
28
  import { V2_AGENT_KEYS, conflictMessage, conflictsOf, toEngineAgent } from './agentDialect.js';
29
+ import { MCP_TRANSPORTS } from './tools/mcpTransports.js';
29
30
  // ── Leaf rules ───────────────────────────────────────────────────────────────
30
31
  /**
31
32
  * ADK's agent-name rule (validateAgentName in ADK's base_agent), kept: checked here
@@ -45,7 +46,7 @@ const thinkingConfig = z
45
46
  includeThoughts: z.boolean().optional().describe('Stream the thinking trace in the response.'),
46
47
  })
47
48
  .describe('Older spelling of `reasoning` for Gemini and Claude (thinkingLevel or thinkingBudget). Cannot be combined with `reasoning`.');
48
- export const REASONING_LEVELS = ['none', 'low', 'medium', 'high'];
49
+ export const REASONING_LEVELS = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
49
50
  /**
50
51
  * The provider-neutral reasoning key (ADR 0047): a level, or a token budget.
51
52
  * The compiler maps it per provider (lib/compile.ts reasoningConfig).
@@ -62,7 +63,7 @@ const reasoningSchema = z
62
63
  return `must be one of ${REASONING_LEVELS.join(' | ')}, or { budget_tokens: <integer ≥ 0> } (got ${describeValue(iss.input)}${hint ? ` — did you mean "${hint}"?` : ''})`;
63
64
  },
64
65
  })
65
- .describe('How hard this agent reasons, on any provider: none | low | medium | high, or { budget_tokens: <int> }. Replaces generateContentConfig.thinkingConfig and reasoningEffort (ADR 0047).');
66
+ .describe('How hard this agent reasons, on any provider: none | low | medium | high | xhigh | max, or { budget_tokens: <int> }. xhigh and max reach the models that take them; elsewhere they go as the model\'s highest setting (ADR 0117). Replaces generateContentConfig.thinkingConfig and reasoningEffort (ADR 0047).');
66
67
  /**
67
68
  * Loose on purpose: provider-specific fields (e.g. `toolConfig`) pass through
68
69
  * to the SDK. Only the documented fields are typed, so `temperature: hot`
@@ -89,7 +90,7 @@ const generateContentConfig = z
89
90
  .optional()
90
91
  .describe('Older spelling of `reasoning` for the chat-completions and Responses providers. Cannot be combined with `reasoning`.'),
91
92
  })
92
- .describe('Deprecated v1 spelling (ADR 0115): write `sampling` (temperature, top_p, max_output_tokens, stop), `output.mime` (responseMimeType) and `reasoning` (thinkingConfig, reasoningEffort) instead; npx melchizedek-codemod rewrites a file. Still read: the Gemini GenerateContentConfig shape the engine takes. Do not set tools here.')
93
+ .describe('Deprecated v1 spelling (ADR 0115): write `sampling` (temperature, top_p, max_output_tokens, stop), `output.mime` (responseMimeType), `reasoning` (thinkingConfig, reasoningEffort) and `tool_choice` (toolConfig.functionCallingConfig) instead; npx melchizedek-codemod rewrites a file. Still read: the Gemini GenerateContentConfig shape the engine takes. Do not set tools here.')
93
94
  .meta({ deprecated: true });
94
95
  // ── YAML v2 agent keys (ADR 0115) ────────────────────────────────────────────
95
96
  /** `sampling` and `output` must say something: an empty block is a leftover. */
@@ -116,6 +117,20 @@ const outputSchemaV2 = z
116
117
  })
117
118
  .refine(nonEmpty, { message: 'must set schema or mime', when: quiet })
118
119
  .describe('What this agent\'s final reply is (ADR 0115). Replaces outputSchema and generateContentConfig.responseMimeType; cannot be combined with them.');
120
+ export const TOOL_CHOICE_MODES = ['auto', 'none', 'required'];
121
+ const toolChoiceSchema = z
122
+ .union([
123
+ z.enum(TOOL_CHOICE_MODES),
124
+ z.strictObject({
125
+ name: z.string().min(1).describe('The one tool the model must call: a name from this agent\'s tools, subagents or MCP tools.'),
126
+ }),
127
+ ], {
128
+ error: (iss) => {
129
+ const hint = typeof iss.input === 'string' ? suggest(iss.input, TOOL_CHOICE_MODES) : undefined;
130
+ return `must be one of ${TOOL_CHOICE_MODES.join(' | ')}, or { name: <tool> } (got ${describeValue(iss.input)}${hint ? ` — did you mean "${hint}"?` : ''})`;
131
+ },
132
+ })
133
+ .describe('Which tools the model may call, on any provider (ADR 0117): auto (the default), none, required (some tool), or { name: <tool> } (that tool). A provider that rejects forcing sends a weaker choice and marks the span llm.tool_choice.weakened. Replaces generateContentConfig.toolConfig.functionCallingConfig; cannot be combined with it.');
119
134
  const modelOverrideEntry = z
120
135
  .strictObject({
121
136
  instruction: z.string().min(1).optional().describe('Replaces the agent\'s instruction on this provider.'),
@@ -203,8 +218,12 @@ const oauth2Auth = z
203
218
  grant: z
204
219
  .enum(['authorization_code', 'client_credentials'])
205
220
  .describe('authorization_code: each user\'s own token, granted through the consent pause (ADR 0085). client_credentials: the server\'s own token, from the token endpoint.'),
206
- authorization_url: z.string().url().optional().describe('The provider\'s authorization endpoint (https). authorization_code only.'),
207
- token_url: z.string().url().describe('The provider\'s token endpoint (https).'),
221
+ client_registration: z
222
+ .literal('dynamic')
223
+ .optional()
224
+ .describe('"dynamic": an MCP server\'s authorization_code grant with no pre-issued client. The authorization server is discovered from the MCP server (RFC 9728, RFC 8414) and this deployment registers itself as a public PKCE client (RFC 7591), once per server and redirect URI, sealed in the credential store (ADR 0124). Every discovered host must be on the operator\'s OAuth host allowlist. MCP servers only; no client id, client secret or endpoint URL beside it.'),
225
+ authorization_url: z.string().url().optional().describe('The provider\'s authorization endpoint (https). authorization_code only; discovered with client_registration: dynamic.'),
226
+ token_url: z.string().url().optional().describe('The provider\'s token endpoint (https). Required, except with client_registration: dynamic, which discovers it.'),
208
227
  client_id: z.string().min(1).max(512).optional().describe('The OAuth client id, written out (it is not a secret). Or client_id_env.'),
209
228
  client_id_env: envName.optional().describe('Environment variable holding the client id. Or client_id.'),
210
229
  client_secret_env: envName
@@ -214,13 +233,25 @@ const oauth2Auth = z
214
233
  authorization_params: z
215
234
  .record(z.string().regex(/^[A-Za-z0-9_.-]{1,64}$/), z.string().max(512))
216
235
  .optional()
217
- .describe('Extra authorization parameters (e.g. access_type: offline). authorization_code only; never state, redirect_uri, scope, client_id or the PKCE pair.'),
236
+ .describe('Extra authorization parameters (e.g. access_type: offline). authorization_code only; never state, redirect_uri, scope, client_id, resource or the PKCE pair.'),
218
237
  })
219
238
  .superRefine((o, ctx) => {
220
- if (!!o.client_id === !!o.client_id_env)
221
- ctx.addIssue({ code: 'custom', path: ['client_id'], message: 'exactly one of client_id or client_id_env' });
222
- if (o.grant === 'authorization_code' && !o.authorization_url) {
223
- ctx.addIssue({ code: 'custom', path: ['authorization_url'], message: 'an authorization_code grant needs authorization_url' });
239
+ if (o.client_registration === 'dynamic') {
240
+ if (o.grant !== 'authorization_code')
241
+ ctx.addIssue({ code: 'custom', path: ['client_registration'], message: 'client_registration: dynamic is for an authorization_code grant' });
242
+ for (const key of ['client_id', 'client_id_env', 'client_secret_env', 'authorization_url', 'token_url']) {
243
+ if (o[key] !== undefined)
244
+ ctx.addIssue({ code: 'custom', path: [key], message: `${key} cannot be combined with client_registration: dynamic, which discovers the endpoints and registers the client (ADR 0124)` });
245
+ }
246
+ }
247
+ else {
248
+ if (!!o.client_id === !!o.client_id_env)
249
+ ctx.addIssue({ code: 'custom', path: ['client_id'], message: 'exactly one of client_id or client_id_env' });
250
+ if (o.token_url === undefined)
251
+ ctx.addIssue({ code: 'custom', path: ['token_url'], message: 'token_url is required (on an MCP server, client_registration: dynamic discovers it)' });
252
+ if (o.grant === 'authorization_code' && !o.authorization_url) {
253
+ ctx.addIssue({ code: 'custom', path: ['authorization_url'], message: 'an authorization_code grant needs authorization_url' });
254
+ }
224
255
  }
225
256
  if (o.grant === 'client_credentials') {
226
257
  if (!o.client_secret_env)
@@ -230,13 +261,37 @@ const oauth2Auth = z
230
261
  ctx.addIssue({ code: 'custom', path: [key], message: `${key} is for authorization_code only` });
231
262
  }
232
263
  }
233
- const reserved = ['state', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'client_id', 'response_type', 'scope'];
264
+ const reserved = ['state', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'client_id', 'response_type', 'scope', 'resource'];
234
265
  for (const key of Object.keys(o.authorization_params ?? {})) {
235
266
  if (reserved.includes(key))
236
267
  ctx.addIssue({ code: 'custom', path: ['authorization_params', key], message: `authorization_params may not set "${key}"` });
237
268
  }
238
269
  })
239
270
  .describe('An OAuth 2 access token sent as a bearer token on every call (lib/tools/oauthTools.ts). Secrets come from environment variable names, never values.');
271
+ // ── MCP (ADR 0124) ───────────────────────────────────────────────────────────
272
+ const mcpTransport = z
273
+ .enum(MCP_TRANSPORTS)
274
+ .describe('How the MCP server is reached: auto (the default) tries Streamable HTTP and falls back to the legacy SSE transport on the spec\'s signal; streamable_http or sse use that one only (ADR 0124).');
275
+ /** An `mcp_servers` entry's name: lowercase, bounded, one character class (no backtracking). */
276
+ export const MCP_SERVER_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
277
+ const mcpServerEntry = z
278
+ .strictObject({
279
+ name: z
280
+ .string()
281
+ .regex(MCP_SERVER_NAME, 'lowercase letters, digits, _ and -, starting with a letter (at most 64)')
282
+ .describe('A short name for the server, unique on this agent: logs and errors name it.'),
283
+ url: z.string().url().describe('The MCP server\'s URL: its Streamable HTTP endpoint, or a legacy SSE endpoint. Checked by the SSRF guard.'),
284
+ tools: z
285
+ .array(z.string().min(1))
286
+ .min(1)
287
+ .describe('The server\'s tools this agent may use, by name; any other tool it lists is not exposed. A name may appear on one server only, and never among the agent\'s own tools. require_approval may name these.'),
288
+ auth: z
289
+ .strictObject({ oauth2: oauth2Auth })
290
+ .optional()
291
+ .describe('The OAuth grant this server takes (ADR 0112, ADR 0124). Without it, MCP_BEARER_TOKENS applies.'),
292
+ transport: mcpTransport.optional(),
293
+ })
294
+ .describe('One MCP server among several (ADR 0124).');
240
295
  // ── OpenAPI ──────────────────────────────────────────────────────────────────
241
296
  const openapiEntry = z
242
297
  .strictObject({
@@ -302,6 +357,7 @@ const agentFields = {
302
357
  reasoning: reasoningSchema.optional(),
303
358
  sampling: samplingSchema.optional(),
304
359
  output: outputSchemaV2.optional(),
360
+ tool_choice: toolChoiceSchema.optional(),
305
361
  model_overrides: modelOverridesSchema.optional(),
306
362
  outputSchema: z
307
363
  .record(z.string(), z.unknown())
@@ -311,7 +367,7 @@ const agentFields = {
311
367
  mcp_server_url: z
312
368
  .string()
313
369
  .optional()
314
- .describe('MCP server (SSE) whose tools are discovered at runtime and merged with `tools`.'),
370
+ .describe('MCP server (Streamable HTTP, or legacy SSE) whose tools are discovered at runtime and merged with `tools`. For more than one server, use mcp_servers.'),
315
371
  mcp_tools: z
316
372
  .array(z.string().min(1))
317
373
  .min(1)
@@ -321,6 +377,13 @@ const agentFields = {
321
377
  .strictObject({ oauth2: oauth2Auth })
322
378
  .optional()
323
379
  .describe('The OAuth grant the MCP server takes (ADR 0112). With authorization_code, each user\'s own token on their own connection, and mcp_tools is required. Needs mcp_server_url; replaces MCP_BEARER_TOKENS for this server.'),
380
+ mcp_transport: mcpTransport.optional().describe('How mcp_server_url is reached: auto (the default), streamable_http or sse (ADR 0124). Needs mcp_server_url.'),
381
+ mcp_servers: z
382
+ .array(mcpServerEntry)
383
+ .min(1)
384
+ .max(16)
385
+ .optional()
386
+ .describe('Several MCP servers, each with the tools this agent may use from it, its grant and its transport (ADR 0124). Cannot be combined with mcp_server_url.'),
324
387
  skills: skillsSchema.optional(),
325
388
  openapi: z.array(openapiEntry).min(1).optional(),
326
389
  code_execution: z
@@ -374,7 +437,7 @@ export const subagentSchema = z
374
437
  .string()
375
438
  .min(1)
376
439
  .optional()
377
- .describe('A whole nested syndicate (filename under the agents dir) used as this subagent. An approval request or ask_user question raised inside it pauses the turn with the agent path (ADR 0110, ADR 0111): a delegate syndicate\'s gates anywhere, a dispatch syndicate\'s on its classifier (its routes never run nested), and a workflow\'s ask_user nodes and gates when it is delegated to as a subagent.'),
440
+ .describe('A whole nested syndicate (filename under the agents dir) used as this subagent. An approval request or ask_user question raised inside it pauses the turn with the agent path (ADR 0110, ADR 0111): a delegate syndicate\'s gates anywhere, a dispatch syndicate\'s on its routes, which it classifies and runs as at the top, its route\'s answer being its own (ADR 0120), and a workflow\'s ask_user nodes and gates, wherever each runs: delegated to as a subagent, as a dispatch route or as a workflow node (ADR 0119).'),
378
441
  a2a_agent_url: z
379
442
  .string()
380
443
  .min(1)
@@ -663,6 +726,61 @@ export const REASONING_OLDER_SPELLING = ['thinkingConfig', 'reasoningEffort'];
663
726
  const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
664
727
  /** Same normalisation lib/dispatch.ts uses to match a route to a subagent. */
665
728
  const routeNorm = (s) => s.replace(/[^a-z0-9]/gi, '').toLowerCase();
729
+ /** Every tool name an agent's `mcp_servers` entries list. */
730
+ function mcpServerTools(agent) {
731
+ return (Array.isArray(agent.mcp_servers) ? agent.mcp_servers : []).flatMap((e) => (isObj(e) && Array.isArray(e.tools) ? e.tools.filter((t) => typeof t === 'string') : []));
732
+ }
733
+ /**
734
+ * An agent's `mcp_servers` against itself and its other keys (ADR 0124):
735
+ * not beside `mcp_server_url`; names unique; a tool name on one server only
736
+ * and never among the agent's own `tools` or OpenAPI `operations`, so no
737
+ * server can shadow another's tool or the agent's own; and dynamic client
738
+ * registration only here or under `mcp_auth`, never on an OpenAPI entry.
739
+ */
740
+ function mcpServersProblems(agent, path) {
741
+ const out = [];
742
+ (Array.isArray(agent.openapi) ? agent.openapi : []).forEach((e, i) => {
743
+ if (isObj(e) && isObj(e.auth) && isObj(e.auth.oauth2) && e.auth.oauth2.client_registration !== undefined) {
744
+ out.push({ path: [...path, 'openapi', i, 'auth', 'oauth2', 'client_registration'], message: 'client_registration: dynamic discovers its endpoints from an MCP server; an OpenAPI entry names its own (ADR 0124)' });
745
+ }
746
+ });
747
+ if (!Array.isArray(agent.mcp_servers))
748
+ return out;
749
+ if (agent.mcp_server_url !== undefined) {
750
+ out.push({ path: [...path, 'mcp_servers'], message: 'cannot be combined with mcp_server_url: list that server as one more mcp_servers entry (ADR 0124)' });
751
+ }
752
+ const own = new Set([
753
+ ...(Array.isArray(agent.tools) ? agent.tools.filter((t) => typeof t === 'string') : []),
754
+ ...(Array.isArray(agent.openapi) ? agent.openapi : []).flatMap((e) => (isObj(e) && Array.isArray(e.operations) ? e.operations.filter((t) => typeof t === 'string') : [])),
755
+ ]);
756
+ const names = new Map();
757
+ const owner = new Map();
758
+ agent.mcp_servers.forEach((entry, i) => {
759
+ if (!isObj(entry))
760
+ return;
761
+ const name = typeof entry.name === 'string' ? entry.name : `#${i}`;
762
+ if (typeof entry.name === 'string') {
763
+ if (names.has(entry.name))
764
+ out.push({ path: [...path, 'mcp_servers', i, 'name'], message: `'${entry.name}' names mcp_servers entry ${names.get(entry.name)} too; each server's name is unique on the agent` });
765
+ else
766
+ names.set(entry.name, i);
767
+ }
768
+ (Array.isArray(entry.tools) ? entry.tools : []).forEach((tool, j) => {
769
+ if (typeof tool !== 'string')
770
+ return;
771
+ if (own.has(tool)) {
772
+ out.push({ path: [...path, 'mcp_servers', i, 'tools', j], message: `'${tool}' is also one of this agent's own tools; an MCP server's tool may not share a name with it (ADR 0124)` });
773
+ }
774
+ else if (owner.has(tool) && owner.get(tool) !== name) {
775
+ out.push({ path: [...path, 'mcp_servers', i, 'tools', j], message: `'${tool}' is also listed on MCP server '${owner.get(tool)}'; a tool name may come from one server only (ADR 0124)` });
776
+ }
777
+ else {
778
+ owner.set(tool, name);
779
+ }
780
+ });
781
+ });
782
+ return out;
783
+ }
666
784
  /**
667
785
  * Rules that span fields, which a JSON Schema an editor reads cannot express
668
786
  * cleanly. Defensive about shape: runs on raw input alongside zod, so a file
@@ -698,25 +816,19 @@ function crossFieldProblems(raw) {
698
816
  // An OpenAPI operation can be gated once it is named under `operations`,
699
817
  // and an MCP tool once it is named under `mcp_tools`.
700
818
  const operations = (Array.isArray(agent.openapi) ? agent.openapi : []).flatMap((e) => (isObj(e) && Array.isArray(e.operations) ? e.operations : []));
701
- const mcpTools = Array.isArray(agent.mcp_tools) ? agent.mcp_tools : [];
819
+ const mcpTools = [...(Array.isArray(agent.mcp_tools) ? agent.mcp_tools : []), ...mcpServerTools(agent)];
702
820
  (Array.isArray(agent.require_approval) ? agent.require_approval : []).forEach((name, j) => {
703
821
  if (typeof name === 'string' && !tools.includes(name) && !operations.includes(name) && !mcpTools.includes(name)) {
704
- out.push({ path: [...path, 'require_approval', j], message: `'${name}' is not in this agent's tools, its openapi operations or its mcp_tools` });
822
+ out.push({ path: [...path, 'require_approval', j], message: `'${name}' is not in this agent's tools, its openapi operations or its mcp_tools (or an mcp_servers entry's tools)` });
705
823
  }
706
824
  });
707
825
  };
708
826
  // A skill script run pauses for approval the same way, so it is allowed in
709
- // the same places; `skills.tools` may only unlock tools the agent does not
710
- // already carry outright.
711
- const skillProblems = (agent, path, allowed) => {
827
+ // the same places, a delegated subagent included (ADR 0118); `skills.tools`
828
+ // may only unlock tools the agent does not already carry outright.
829
+ const skillProblems = (agent, path) => {
712
830
  if (!isObj(agent.skills))
713
831
  return;
714
- if (agent.skills.scripts === 'local' && !allowed) {
715
- out.push({
716
- path: [...path, 'skills', 'scripts'],
717
- message: 'skill scripts on a delegated subagent are not supported yet; run them on the orchestrator or a plan-dispatch route (ADR 0110)',
718
- });
719
- }
720
832
  const tools = Array.isArray(agent.tools) ? agent.tools : [];
721
833
  (Array.isArray(agent.skills.tools) ? agent.skills.tools : []).forEach((name, j) => {
722
834
  if (typeof name === 'string' && tools.includes(name)) {
@@ -763,6 +875,10 @@ function crossFieldProblems(raw) {
763
875
  if (isObj(agent.mcp_auth) && isObj(agent.mcp_auth.oauth2) && agent.mcp_auth.oauth2.grant === 'authorization_code' && agent.mcp_tools === undefined) {
764
876
  out.push({ path: [...path, 'mcp_auth'], message: 'an authorization_code mcp_auth needs mcp_tools: no user\'s grant exists at startup to list the server\'s tools' });
765
877
  }
878
+ if (agent.mcp_transport !== undefined && typeof agent.mcp_server_url !== 'string') {
879
+ out.push({ path: [...path, 'mcp_transport'], message: 'mcp_transport is how mcp_server_url is reached; it needs mcp_server_url (an mcp_servers entry names its own transport)' });
880
+ }
881
+ out.push(...mcpServersProblems(agent, path));
766
882
  };
767
883
  if (isObj(raw.orchestrator))
768
884
  executionProblems(raw.orchestrator, ['orchestrator'], true);
@@ -823,19 +939,17 @@ function crossFieldProblems(raw) {
823
939
  v2Problems(sub, ['subagents', i]);
824
940
  if (isObj(raw.orchestrator)) {
825
941
  gateProblems(raw.orchestrator, ['orchestrator'], true);
826
- skillProblems(raw.orchestrator, ['orchestrator'], true);
942
+ skillProblems(raw.orchestrator, ['orchestrator']);
827
943
  questionProblems(raw.orchestrator, ['orchestrator'], true);
828
944
  }
829
- const dispatching = isObj(raw.dispatch);
830
945
  // A workflow's subagents are its nodes, not delegated tools: a gated call pauses its node, and
831
946
  // the walk resumes it (ADR 0098); a skill script run pauses on the same approval (ADR 0106). A map item cannot (workflowProblems).
832
- const pausing = dispatching || isObj(raw.workflow);
833
947
  subs.forEach((sub, i) => {
834
948
  if (!isObj(sub))
835
949
  return;
836
- // A delegated subagent's gate and question pause the turn through the open call (ADR 0110); its skill scripts stay refused.
950
+ // A delegated subagent's gate, question and skill script run pause the turn through the open call (ADR 0110, ADR 0118).
837
951
  gateProblems(sub, ['subagents', i], true);
838
- skillProblems(sub, ['subagents', i], pausing);
952
+ skillProblems(sub, ['subagents', i]);
839
953
  questionProblems(sub, ['subagents', i], true);
840
954
  const hasRef = typeof sub.yaml_reference === 'string';
841
955
  const hasRemote = typeof sub.a2a_agent_url === 'string';
@@ -93,14 +93,26 @@ export interface CredentialStore {
93
93
  /** What the ToolContext member gives a tool: a valid access token for a provider, for this run's user only. */
94
94
  export type ToolAccessToken = (provider: string) => Promise<string>;
95
95
  export type ToolCredentialErrorCode = 'not_connected' | 'expired' | 'refresh_failed' | 'unreadable' | 'no_user' | 'invalid' | 'unavailable' | 'grant_failed' | 'host_refused';
96
+ /**
97
+ * Which operator allowlist refused a `host_refused` call: the OAuth host
98
+ * allowlist, which binds a provider's tokens to its hosts
99
+ * (`MELCHIZEDEK_OAUTH_HOSTS`, ADR 0114), or the credential host allowlist,
100
+ * which binds a static credential variable such as a client secret to its
101
+ * hosts (`MELCHIZEDEK_CREDENTIAL_HOSTS`, ADR 0122).
102
+ */
103
+ export type HostAllowlist = 'oauth' | 'credential';
96
104
  /**
97
105
  * A credential that cannot be used. The message names the provider and what
98
106
  * to do, never a token, a user or a key, so a tool may return it to the model.
107
+ * A `host_refused` error names the allowlist that refused (`allowlist`,
108
+ * `'oauth'` when not given).
99
109
  */
100
110
  export declare class ToolCredentialError extends Error {
101
111
  readonly code: ToolCredentialErrorCode;
102
112
  readonly provider?: string;
103
- constructor(code: ToolCredentialErrorCode, provider?: string);
113
+ /** For `host_refused`: the allowlist that refused the host. */
114
+ readonly allowlist?: HostAllowlist;
115
+ constructor(code: ToolCredentialErrorCode, provider?: string, allowlist?: HostAllowlist);
104
116
  }
105
117
  /** A provider name: lowercase letters, digits, and . _ - inside; at most 64 characters. */
106
118
  export declare const PROVIDER_NAME: RegExp;
@@ -32,11 +32,15 @@
32
32
  /**
33
33
  * A credential that cannot be used. The message names the provider and what
34
34
  * to do, never a token, a user or a key, so a tool may return it to the model.
35
+ * A `host_refused` error names the allowlist that refused (`allowlist`,
36
+ * `'oauth'` when not given).
35
37
  */
36
38
  export class ToolCredentialError extends Error {
37
39
  code;
38
40
  provider;
39
- constructor(code, provider) {
41
+ /** For `host_refused`: the allowlist that refused the host. */
42
+ allowlist;
43
+ constructor(code, provider, allowlist) {
40
44
  const p = provider ? `"${provider}"` : 'this provider';
41
45
  super(code === 'not_connected'
42
46
  ? `No ${p} account is connected for this user; ask them to connect it.`
@@ -53,11 +57,15 @@ export class ToolCredentialError extends Error {
53
57
  : code === 'grant_failed'
54
58
  ? `The server's own ${p} authorization could not be obtained from the provider's token endpoint.`
55
59
  : code === 'host_refused'
56
- ? `The ${p} authorization may not be sent to this host: the operator's OAuth host allowlist does not include it.`
60
+ ? allowlist === 'credential'
61
+ ? `The ${p} client secret may not be sent to this host: the operator's credential host allowlist (MELCHIZEDEK_CREDENTIAL_HOSTS) does not bind it there.`
62
+ : `The ${p} authorization may not be sent to this host: the operator's OAuth host allowlist (MELCHIZEDEK_OAUTH_HOSTS) does not include it.`
57
63
  : `Invalid credential request for ${p}.`);
58
64
  this.name = 'ToolCredentialError';
59
65
  this.code = code;
60
66
  this.provider = provider;
67
+ if (code === 'host_refused')
68
+ this.allowlist = allowlist ?? 'oauth';
61
69
  }
62
70
  }
63
71
  /** A provider name: lowercase letters, digits, and . _ - inside; at most 64 characters. */
@@ -0,0 +1,88 @@
1
+ /**
2
+ * lib/tools/credentialHosts.ts — the operator's credential host allowlist:
3
+ * which hosts the value of each static credential variable may be sent to
4
+ * (ADR 0122).
5
+ *
6
+ * WHY this file exists:
7
+ * A syndicate YAML (possibly registry-stored) names a credential variable
8
+ * and, beside it, the host its value goes to: an OpenAPI `auth` sends
9
+ * `bearer_env` or `api_key.env` to the spec's server (or `base_url`), and
10
+ * an `oauth2` block sends `client_secret_env` to its `token_url`.
11
+ * credentialEnvProblem (lib/tools/credentialEnv.ts) keeps the framework's
12
+ * own secrets out of reach, and the OAuth host allowlist (ADR 0114) binds
13
+ * a provider's tokens, but nothing bound a static secret to its API: a
14
+ * YAML that names TRACKER_TOKEN could aim it at a host of its own. The
15
+ * binding is the operator's, never the YAML's: an environment variable or
16
+ * `createA2AApp({ credentialHosts })`.
17
+ *
18
+ * THE RULE (ADR 0122):
19
+ * - With no allowlist configured, nothing changes: a credential goes to
20
+ * the host the YAML names, as before. The doctor and the server binary
21
+ * name every credential variable a served YAML sends, as unbound.
22
+ * - With an allowlist configured, it is the whole list: every credential
23
+ * variable a YAML sends must be on it, and every host its value goes to
24
+ * must be one of that variable's hosts.
25
+ * - The check runs when a syndicate is loaded to be served, when its tools
26
+ * are compiled, and again before each call sends the value.
27
+ *
28
+ * WHAT IT COVERS: `bearer_env`, `api_key.env` and `client_secret_env` (both
29
+ * grants: the client-credentials token request, the consent step's code
30
+ * exchange and its refresh). Not `client_id_env`: a client id is not a
31
+ * secret. Not MCP_BEARER_TOKENS: it maps each token to its host already,
32
+ * and the operator writes both.
33
+ *
34
+ * THE FORMAT: `MELCHIZEDEK_CREDENTIAL_HOSTS="TRACKER_TOKEN=api.tracker.example.com;WEATHER_KEY=api.weather.example.com,*.weather.example.com"`.
35
+ * Entries are separated by `;` (or a newline), a variable from its hosts by
36
+ * `=`, hosts by `,`. A host is what the OAuth host allowlist takes
37
+ * (lib/tools/oauthHosts.ts): a hostname, `*.example.com`, IPv4 or
38
+ * `[IPv6]`, with no scheme, port or path.
39
+ *
40
+ * Nothing here reads or prints a credential's value: variable names and
41
+ * hosts only. No regular expression here can backtrack: the separators are
42
+ * single characters, and a variable name is one bounded character class.
43
+ */
44
+ /** The environment variable the allowlist is read from when no option sets it. */
45
+ export declare const CREDENTIAL_HOSTS_ENV = "MELCHIZEDEK_CREDENTIAL_HOSTS";
46
+ /** Credential variable name → the hosts its value may be sent to. */
47
+ export type CredentialHostAllowlist = Readonly<Record<string, readonly string[]>>;
48
+ /**
49
+ * The allowlist, checked and normalized (hosts lowercased, duplicates
50
+ * dropped). Throws, naming the variable and the host, on anything malformed.
51
+ */
52
+ export declare function checkCredentialHosts(allowlist: Record<string, readonly string[]>, source?: string): Record<string, string[]>;
53
+ /** The allowlist from its text form (MELCHIZEDEK_CREDENTIAL_HOSTS). Throws on a malformed entry. */
54
+ export declare function parseCredentialHosts(text: string): Record<string, string[]>;
55
+ /**
56
+ * Sets this process's allowlist (createA2AApp's `credentialHosts` does),
57
+ * which wins over the environment variable. `undefined` returns to the
58
+ * variable.
59
+ */
60
+ export declare function setCredentialHosts(allowlist: Record<string, readonly string[]> | undefined): void;
61
+ /**
62
+ * The allowlist in force: the one set by `setCredentialHosts`, else the
63
+ * environment variable's, else undefined (not configured). A malformed
64
+ * variable throws, so the first check refuses rather than allowing.
65
+ */
66
+ export declare function credentialHosts(env?: NodeJS.ProcessEnv): CredentialHostAllowlist | undefined;
67
+ /**
68
+ * Why the value of credential variable `name` may not be sent to `url`, or
69
+ * null. `role` names the URL in the message (`server`, `token_url`).
70
+ * `allowlist` defaults to the one in force; `null` means none is
71
+ * configured, which allows (the behaviour before ADR 0122).
72
+ */
73
+ export declare function credentialHostProblem(name: string, url: string, role?: string, allowlist?: CredentialHostAllowlist | null | undefined): string | null;
74
+ /**
75
+ * The call-time check: why the value of `name` may not be sent to
76
+ * `destination` now, or null. A malformed allowlist refuses.
77
+ */
78
+ export declare function credentialCallProblem(name: string, destination: string): string | null;
79
+ /**
80
+ * Marks an OAuth client config (lib/tools/oauthConsent.ts) with the
81
+ * variable its client secret came from, so the consent step's code exchange
82
+ * and the refresh hook can check the token endpoint at call time. Set by
83
+ * oauthClientsFor (lib/tools/oauthTools.ts); a client built by hand carries
84
+ * none and is not checked.
85
+ */
86
+ export declare const CLIENT_SECRET_ENV: unique symbol;
87
+ /** The variable a client config's secret came from, when oauthClientsFor marked it. */
88
+ export declare function clientSecretEnvOf(client: object): string | undefined;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * lib/tools/credentialHosts.ts — the operator's credential host allowlist:
3
+ * which hosts the value of each static credential variable may be sent to
4
+ * (ADR 0122).
5
+ *
6
+ * WHY this file exists:
7
+ * A syndicate YAML (possibly registry-stored) names a credential variable
8
+ * and, beside it, the host its value goes to: an OpenAPI `auth` sends
9
+ * `bearer_env` or `api_key.env` to the spec's server (or `base_url`), and
10
+ * an `oauth2` block sends `client_secret_env` to its `token_url`.
11
+ * credentialEnvProblem (lib/tools/credentialEnv.ts) keeps the framework's
12
+ * own secrets out of reach, and the OAuth host allowlist (ADR 0114) binds
13
+ * a provider's tokens, but nothing bound a static secret to its API: a
14
+ * YAML that names TRACKER_TOKEN could aim it at a host of its own. The
15
+ * binding is the operator's, never the YAML's: an environment variable or
16
+ * `createA2AApp({ credentialHosts })`.
17
+ *
18
+ * THE RULE (ADR 0122):
19
+ * - With no allowlist configured, nothing changes: a credential goes to
20
+ * the host the YAML names, as before. The doctor and the server binary
21
+ * name every credential variable a served YAML sends, as unbound.
22
+ * - With an allowlist configured, it is the whole list: every credential
23
+ * variable a YAML sends must be on it, and every host its value goes to
24
+ * must be one of that variable's hosts.
25
+ * - The check runs when a syndicate is loaded to be served, when its tools
26
+ * are compiled, and again before each call sends the value.
27
+ *
28
+ * WHAT IT COVERS: `bearer_env`, `api_key.env` and `client_secret_env` (both
29
+ * grants: the client-credentials token request, the consent step's code
30
+ * exchange and its refresh). Not `client_id_env`: a client id is not a
31
+ * secret. Not MCP_BEARER_TOKENS: it maps each token to its host already,
32
+ * and the operator writes both.
33
+ *
34
+ * THE FORMAT: `MELCHIZEDEK_CREDENTIAL_HOSTS="TRACKER_TOKEN=api.tracker.example.com;WEATHER_KEY=api.weather.example.com,*.weather.example.com"`.
35
+ * Entries are separated by `;` (or a newline), a variable from its hosts by
36
+ * `=`, hosts by `,`. A host is what the OAuth host allowlist takes
37
+ * (lib/tools/oauthHosts.ts): a hostname, `*.example.com`, IPv4 or
38
+ * `[IPv6]`, with no scheme, port or path.
39
+ *
40
+ * Nothing here reads or prints a credential's value: variable names and
41
+ * hosts only. No regular expression here can backtrack: the separators are
42
+ * single characters, and a variable name is one bounded character class.
43
+ */
44
+ import { hostAllowed, hostPatternProblem } from './oauthHosts.js';
45
+ /** The environment variable the allowlist is read from when no option sets it. */
46
+ export const CREDENTIAL_HOSTS_ENV = 'MELCHIZEDEK_CREDENTIAL_HOSTS';
47
+ /** A credential variable's name, as a YAML may write it (lib/syndicateSchema.ts), bounded. */
48
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
49
+ /**
50
+ * The allowlist, checked and normalized (hosts lowercased, duplicates
51
+ * dropped). Throws, naming the variable and the host, on anything malformed.
52
+ */
53
+ export function checkCredentialHosts(allowlist, source = 'credentialHosts') {
54
+ if (!allowlist || typeof allowlist !== 'object' || Array.isArray(allowlist))
55
+ throw new Error(`${source} must map each credential variable to its hosts.`);
56
+ const out = {};
57
+ for (const [name, hosts] of Object.entries(allowlist)) {
58
+ if (!ENV_NAME.test(name))
59
+ throw new Error(`${source}: "${name.slice(0, 64)}" is not an environment variable name (A–Z, 0–9, _).`);
60
+ if (!Array.isArray(hosts) || hosts.length === 0)
61
+ throw new Error(`${source}: ${name} needs at least one host.`);
62
+ const seen = new Set();
63
+ for (const raw of hosts) {
64
+ const host = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
65
+ const problem = host ? hostPatternProblem(host) : 'is empty';
66
+ if (problem)
67
+ throw new Error(`${source}: ${name}: host "${String(raw).slice(0, 80)}" ${problem}.`);
68
+ seen.add(host);
69
+ }
70
+ out[name] = [...seen];
71
+ }
72
+ return out;
73
+ }
74
+ /** The allowlist from its text form (MELCHIZEDEK_CREDENTIAL_HOSTS). Throws on a malformed entry. */
75
+ export function parseCredentialHosts(text) {
76
+ const parsed = {};
77
+ for (const entry of text.split(/[;\n]/)) {
78
+ const trimmed = entry.trim();
79
+ if (!trimmed)
80
+ continue;
81
+ const eq = trimmed.indexOf('=');
82
+ if (eq <= 0)
83
+ throw new Error(`${CREDENTIAL_HOSTS_ENV}: each entry is VARIABLE=host,host (got "${trimmed.slice(0, 80)}").`);
84
+ const name = trimmed.slice(0, eq).trim();
85
+ if (Object.hasOwn(parsed, name))
86
+ throw new Error(`${CREDENTIAL_HOSTS_ENV}: ${name.slice(0, 64)} is listed twice.`);
87
+ parsed[name] = trimmed.slice(eq + 1).split(',').map((h) => h.trim()).filter((h) => h !== '');
88
+ }
89
+ return checkCredentialHosts(parsed, CREDENTIAL_HOSTS_ENV);
90
+ }
91
+ let configured;
92
+ let envCache;
93
+ /**
94
+ * Sets this process's allowlist (createA2AApp's `credentialHosts` does),
95
+ * which wins over the environment variable. `undefined` returns to the
96
+ * variable.
97
+ */
98
+ export function setCredentialHosts(allowlist) {
99
+ configured = allowlist === undefined ? undefined : checkCredentialHosts(allowlist);
100
+ }
101
+ /**
102
+ * The allowlist in force: the one set by `setCredentialHosts`, else the
103
+ * environment variable's, else undefined (not configured). A malformed
104
+ * variable throws, so the first check refuses rather than allowing.
105
+ */
106
+ export function credentialHosts(env = process.env) {
107
+ if (configured)
108
+ return configured;
109
+ const raw = env[CREDENTIAL_HOSTS_ENV];
110
+ if (raw === undefined || raw.trim() === '')
111
+ return undefined;
112
+ if (envCache?.raw !== raw)
113
+ envCache = { raw, value: parseCredentialHosts(raw) };
114
+ return envCache.value;
115
+ }
116
+ /**
117
+ * Why the value of credential variable `name` may not be sent to `url`, or
118
+ * null. `role` names the URL in the message (`server`, `token_url`).
119
+ * `allowlist` defaults to the one in force; `null` means none is
120
+ * configured, which allows (the behaviour before ADR 0122).
121
+ */
122
+ export function credentialHostProblem(name, url, role = 'server', allowlist = credentialHosts()) {
123
+ const list = allowlist ?? undefined;
124
+ if (!list)
125
+ return null;
126
+ if (!Object.hasOwn(list, name)) {
127
+ return `${name} is not on the operator's credential host allowlist (${CREDENTIAL_HOSTS_ENV} or createA2AApp's credentialHosts), so it is sent nowhere`;
128
+ }
129
+ let hostname;
130
+ try {
131
+ hostname = new URL(url).hostname;
132
+ }
133
+ catch {
134
+ return `${role} '${url.slice(0, 200)}' is not a URL`;
135
+ }
136
+ const hosts = list[name];
137
+ if (hostAllowed(hosts, hostname))
138
+ return null;
139
+ return `${name} may be sent only to ${hosts.join(', ')}; ${hostname} (${role}) is not one of them`;
140
+ }
141
+ /**
142
+ * The call-time check: why the value of `name` may not be sent to
143
+ * `destination` now, or null. A malformed allowlist refuses.
144
+ */
145
+ export function credentialCallProblem(name, destination) {
146
+ try {
147
+ return credentialHostProblem(name, destination, 'server', credentialHosts() ?? null);
148
+ }
149
+ catch {
150
+ return `the credential host allowlist (${CREDENTIAL_HOSTS_ENV}) is malformed`;
151
+ }
152
+ }
153
+ /**
154
+ * Marks an OAuth client config (lib/tools/oauthConsent.ts) with the
155
+ * variable its client secret came from, so the consent step's code exchange
156
+ * and the refresh hook can check the token endpoint at call time. Set by
157
+ * oauthClientsFor (lib/tools/oauthTools.ts); a client built by hand carries
158
+ * none and is not checked.
159
+ */
160
+ export const CLIENT_SECRET_ENV = Symbol.for('melchizedek.clientSecretEnv');
161
+ /** The variable a client config's secret came from, when oauthClientsFor marked it. */
162
+ export function clientSecretEnvOf(client) {
163
+ const name = client[CLIENT_SECRET_ENV];
164
+ return typeof name === 'string' ? name : undefined;
165
+ }