agent-relay-server 0.60.1 → 0.61.1

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 (158) hide show
  1. package/docs/openapi.json +361 -1
  2. package/package.json +3 -2
  3. package/public/assets/{MessageBubble-vzupFQD8.js → MessageBubble-CX63ZV4X.js} +2 -2
  4. package/public/assets/{MessageBubble-vzupFQD8.js.map → MessageBubble-CX63ZV4X.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DQvycBMW.js → PlanGraphPanel-Dby7wq9c.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DQvycBMW.js.map → PlanGraphPanel-Dby7wq9c.js.map} +1 -1
  7. package/public/assets/{activity-gmfkOBhQ.js → activity-2xPOScu_.js} +2 -2
  8. package/public/assets/{activity-gmfkOBhQ.js.map → activity-2xPOScu_.js.map} +1 -1
  9. package/public/assets/{agent-profiles-7X8gFuwj.js → agent-profiles-BfGTTbeY.js} +2 -2
  10. package/public/assets/{agent-profiles-7X8gFuwj.js.map → agent-profiles-BfGTTbeY.js.map} +1 -1
  11. package/public/assets/agents-Bg3WOBRV.js +2 -0
  12. package/public/assets/{agents-D201CuTR.js.map → agents-Bg3WOBRV.js.map} +1 -1
  13. package/public/assets/{analytics-CkOTrr5t.js → analytics-De0cEZGx.js} +3 -3
  14. package/public/assets/{analytics-CkOTrr5t.js.map → analytics-De0cEZGx.js.map} +1 -1
  15. package/public/assets/{automation-_5u-nVs8.js → automation-CHxhzq2t.js} +2 -2
  16. package/public/assets/{automation-_5u-nVs8.js.map → automation-CHxhzq2t.js.map} +1 -1
  17. package/public/assets/{badge-Cy6qO_Jz.js → badge-DhLyCPag.js} +2 -2
  18. package/public/assets/{badge-Cy6qO_Jz.js.map → badge-DhLyCPag.js.map} +1 -1
  19. package/public/assets/branch-state-badge-DSyWKluD.js +2 -0
  20. package/public/assets/{branch-state-badge-BPXXLpQG.js.map → branch-state-badge-DSyWKluD.js.map} +1 -1
  21. package/public/assets/{button-73wpRw65.js → button-K20wAZG1.js} +2 -2
  22. package/public/assets/{button-73wpRw65.js.map → button-K20wAZG1.js.map} +1 -1
  23. package/public/assets/card-CcZ9hLI7.js +2 -0
  24. package/public/assets/{card-BU6PvVHv.js.map → card-CcZ9hLI7.js.map} +1 -1
  25. package/public/assets/{channels-BsJu5TXR.js → channels-BG0Nib7y.js} +2 -2
  26. package/public/assets/{channels-BsJu5TXR.js.map → channels-BG0Nib7y.js.map} +1 -1
  27. package/public/assets/chat-CjP_crSl.js +2 -0
  28. package/public/assets/{chat-CHgPGHQD.js.map → chat-CjP_crSl.js.map} +1 -1
  29. package/public/assets/{connectors-arjfBQvc.js → connectors-DWh7MwV_.js} +2 -2
  30. package/public/assets/{connectors-arjfBQvc.js.map → connectors-DWh7MwV_.js.map} +1 -1
  31. package/public/assets/{copy-button-HRopUwui.js → copy-button-eF3cKp4Q.js} +2 -2
  32. package/public/assets/{copy-button-HRopUwui.js.map → copy-button-eF3cKp4Q.js.map} +1 -1
  33. package/public/assets/display-DzNvOu1j.js +3 -0
  34. package/public/assets/{display-CketawEF.js.map → display-DzNvOu1j.js.map} +1 -1
  35. package/public/assets/{dist-Kkpnjx_M.js → dist-1dhfdhm9.js} +2 -2
  36. package/public/assets/{dist-Kkpnjx_M.js.map → dist-1dhfdhm9.js.map} +1 -1
  37. package/public/assets/dist-BLBF7qqr.js +2 -0
  38. package/public/assets/{dist-De2oguBm.js.map → dist-BLBF7qqr.js.map} +1 -1
  39. package/public/assets/{dist-T7u7IZGJ.js → dist-BrQsqBbc.js} +2 -2
  40. package/public/assets/{dist-T7u7IZGJ.js.map → dist-BrQsqBbc.js.map} +1 -1
  41. package/public/assets/{dist-Cko2KPW8.js → dist-COQuunEv.js} +2 -2
  42. package/public/assets/{dist-Cko2KPW8.js.map → dist-COQuunEv.js.map} +1 -1
  43. package/public/assets/{dist-CLHMeXYi.js → dist-Ci5Y37nj.js} +2 -2
  44. package/public/assets/{dist-CLHMeXYi.js.map → dist-Ci5Y37nj.js.map} +1 -1
  45. package/public/assets/{dist-Ci1MM51P.js → dist-CoROafNg.js} +2 -2
  46. package/public/assets/{dist-Ci1MM51P.js.map → dist-CoROafNg.js.map} +1 -1
  47. package/public/assets/{dist-CC5f7oYw.js → dist-I-MFUv51.js} +2 -2
  48. package/public/assets/{dist-CC5f7oYw.js.map → dist-I-MFUv51.js.map} +1 -1
  49. package/public/assets/{dist-DFLciw0h.js → dist-UQGKwqZH.js} +2 -2
  50. package/public/assets/{dist-DFLciw0h.js.map → dist-UQGKwqZH.js.map} +1 -1
  51. package/public/assets/dist-YpHfnnIA.js +2 -0
  52. package/public/assets/{dist-DUoZnlB2.js.map → dist-YpHfnnIA.js.map} +1 -1
  53. package/public/assets/dist-qd198ib9.js +2 -0
  54. package/public/assets/{dist-D41tzJYg.js.map → dist-qd198ib9.js.map} +1 -1
  55. package/public/assets/{dist-DmN94rc8.js → dist-yGOq6sB3.js} +2 -2
  56. package/public/assets/{dist-DmN94rc8.js.map → dist-yGOq6sB3.js.map} +1 -1
  57. package/public/assets/{es2015-Df08Opjx.js → es2015-CRxz9lOj.js} +2 -2
  58. package/public/assets/{es2015-Df08Opjx.js.map → es2015-CRxz9lOj.js.map} +1 -1
  59. package/public/assets/{formatted-body-impl-DBt5tnS0.js → formatted-body-impl-u0W_hWYA.js} +2 -2
  60. package/public/assets/{formatted-body-impl-DBt5tnS0.js.map → formatted-body-impl-u0W_hWYA.js.map} +1 -1
  61. package/public/assets/index-BwfVOzUG.js +21 -0
  62. package/public/assets/{index-D7js3XlH.js.map → index-BwfVOzUG.js.map} +1 -1
  63. package/public/assets/index-Dn_GDXbi.css +2 -0
  64. package/public/assets/{input-B2EDz-BE.js → input-BHYNpXAG.js} +2 -2
  65. package/public/assets/{input-B2EDz-BE.js.map → input-BHYNpXAG.js.map} +1 -1
  66. package/public/assets/{insights-BvjkOXk9.js → insights-CAF9Hi5D.js} +2 -2
  67. package/public/assets/{insights-BvjkOXk9.js.map → insights-CAF9Hi5D.js.map} +1 -1
  68. package/public/assets/{integrations-7L1f3eIE.js → integrations-CG9qyHUf.js} +2 -2
  69. package/public/assets/{integrations-7L1f3eIE.js.map → integrations-CG9qyHUf.js.map} +1 -1
  70. package/public/assets/{lib-BZrjIp-o.js → lib-Df4aph5u.js} +2 -2
  71. package/public/assets/{lib-BZrjIp-o.js.map → lib-Df4aph5u.js.map} +1 -1
  72. package/public/assets/lucide-react-L2UxFDic.js +9 -0
  73. package/public/assets/lucide-react-L2UxFDic.js.map +1 -0
  74. package/public/assets/{maintenance-BBoL--vs.js → maintenance-DgHA35ve.js} +2 -2
  75. package/public/assets/{maintenance-BBoL--vs.js.map → maintenance-DgHA35ve.js.map} +1 -1
  76. package/public/assets/{managed-agents-BjYBzVZt.js → managed-agents-Rk6ghKR9.js} +2 -2
  77. package/public/assets/{managed-agents-BjYBzVZt.js.map → managed-agents-Rk6ghKR9.js.map} +1 -1
  78. package/public/assets/{markdown-preview-impl-BnwjZsmO.js → markdown-preview-impl-NWMtJdYz.js} +2 -2
  79. package/public/assets/{markdown-preview-impl-BnwjZsmO.js.map → markdown-preview-impl-NWMtJdYz.js.map} +1 -1
  80. package/public/assets/memory-DamwWa9A.js +2 -0
  81. package/public/assets/{memory-SLycz2gC.js.map → memory-DamwWa9A.js.map} +1 -1
  82. package/public/assets/{messages-DfSCTe6N.js → messages--NU-YIL6.js} +2 -2
  83. package/public/assets/{messages-DfSCTe6N.js.map → messages--NU-YIL6.js.map} +1 -1
  84. package/public/assets/{orchestrators-BwxdTQm_.js → orchestrators-DQnIn6qx.js} +2 -2
  85. package/public/assets/{orchestrators-BwxdTQm_.js.map → orchestrators-DQnIn6qx.js.map} +1 -1
  86. package/public/assets/{overview-Djy3qkY5.js → overview-DCmNgXDf.js} +2 -2
  87. package/public/assets/{overview-Djy3qkY5.js.map → overview-DCmNgXDf.js.map} +1 -1
  88. package/public/assets/{pairs-9KNduLnC.js → pairs-DNELeC9P.js} +2 -2
  89. package/public/assets/{pairs-9KNduLnC.js.map → pairs-DNELeC9P.js.map} +1 -1
  90. package/public/assets/{react-dom-WI6TjVe7.js → react-dom-CrP_AtcL.js} +2 -2
  91. package/public/assets/{react-dom-WI6TjVe7.js.map → react-dom-CrP_AtcL.js.map} +1 -1
  92. package/public/assets/{security-tG3q7fFW.js → security-Dd3Kh0oC.js} +2 -2
  93. package/public/assets/{security-tG3q7fFW.js.map → security-Dd3Kh0oC.js.map} +1 -1
  94. package/public/assets/{select-CGUdMNOG.js → select-DQbeJZX0.js} +2 -2
  95. package/public/assets/{select-CGUdMNOG.js.map → select-DQbeJZX0.js.map} +1 -1
  96. package/public/assets/settings-BMAJrBIH.js +4 -0
  97. package/public/assets/settings-BMAJrBIH.js.map +1 -0
  98. package/public/assets/store-B8xP6z6T.js +9 -0
  99. package/public/assets/store-B8xP6z6T.js.map +1 -0
  100. package/public/assets/tasks-BYR4L9GP.js +2 -0
  101. package/public/assets/{tasks-DWlKg218.js.map → tasks-BYR4L9GP.js.map} +1 -1
  102. package/public/assets/teams-CoTifrq_.js +2 -0
  103. package/public/assets/{teams-DRchUP_p.js.map → teams-CoTifrq_.js.map} +1 -1
  104. package/public/assets/{terminal-viewer-impl-D_Sqe-vo.js → terminal-viewer-impl-B1YzfpE6.js} +3 -3
  105. package/public/assets/{terminal-viewer-impl-D_Sqe-vo.js.map → terminal-viewer-impl-B1YzfpE6.js.map} +1 -1
  106. package/public/assets/{use-keyboard-viewport-fOL_3RHB.js → use-keyboard-viewport-DrRH_QzG.js} +2 -2
  107. package/public/assets/{use-keyboard-viewport-fOL_3RHB.js.map → use-keyboard-viewport-DrRH_QzG.js.map} +1 -1
  108. package/public/assets/{work-queue-HNcyjON9.js → work-queue-x57vlgyZ.js} +2 -2
  109. package/public/assets/{work-queue-HNcyjON9.js.map → work-queue-x57vlgyZ.js.map} +1 -1
  110. package/public/assets/{workspaces-CicYE2Ax.js → workspaces-CYm5bLg9.js} +3 -3
  111. package/public/assets/{workspaces-CicYE2Ax.js.map → workspaces-CYm5bLg9.js.map} +1 -1
  112. package/public/index.html +22 -22
  113. package/runner/src/config.ts +66 -2
  114. package/src/automations.ts +3 -4
  115. package/src/config-store.ts +5 -6
  116. package/src/connectors.ts +5 -0
  117. package/src/db/migrations.ts +6 -0
  118. package/src/db/schema.ts +1 -1
  119. package/src/index.ts +5 -2
  120. package/src/mcp-provider-catalog.ts +9 -0
  121. package/src/mcp.ts +4 -4
  122. package/src/provider-catalog-store.ts +33 -4
  123. package/src/provider-config-store.ts +216 -0
  124. package/src/recipe-runner.ts +2 -1
  125. package/src/recipe-validator.ts +4 -3
  126. package/src/routes/agents-spawn.ts +2 -2
  127. package/src/routes/index.ts +12 -2
  128. package/src/routes/insights.ts +4 -3
  129. package/src/routes/orchestrator.ts +21 -1
  130. package/src/routes/provider-config.ts +89 -41
  131. package/src/routes/settings.ts +127 -0
  132. package/src/routes/steward.ts +5 -5
  133. package/src/routes/tokens.ts +32 -1
  134. package/src/security.ts +34 -37
  135. package/src/settings/modules.ts +149 -0
  136. package/src/settings/registry.ts +397 -0
  137. package/src/spawn-command.ts +5 -1
  138. package/src/team-template-config.ts +2 -2
  139. package/src/token-db.ts +20 -0
  140. package/public/assets/agents-D201CuTR.js +0 -2
  141. package/public/assets/branch-state-badge-BPXXLpQG.js +0 -2
  142. package/public/assets/card-BU6PvVHv.js +0 -2
  143. package/public/assets/chat-CHgPGHQD.js +0 -2
  144. package/public/assets/display-CketawEF.js +0 -3
  145. package/public/assets/dist-D41tzJYg.js +0 -2
  146. package/public/assets/dist-DUoZnlB2.js +0 -2
  147. package/public/assets/dist-De2oguBm.js +0 -2
  148. package/public/assets/index-D7js3XlH.js +0 -21
  149. package/public/assets/index-DaNOQJci.css +0 -2
  150. package/public/assets/lucide-react-H_-8G79A.js +0 -9
  151. package/public/assets/lucide-react-H_-8G79A.js.map +0 -1
  152. package/public/assets/memory-SLycz2gC.js +0 -2
  153. package/public/assets/settings-CGcqyJ5L.js +0 -10
  154. package/public/assets/settings-CGcqyJ5L.js.map +0 -1
  155. package/public/assets/store-DXdfhiVf.js +0 -9
  156. package/public/assets/store-DXdfhiVf.js.map +0 -1
  157. package/public/assets/tasks-DWlKg218.js +0 -2
  158. package/public/assets/teams-DRchUP_p.js +0 -2
@@ -2,8 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir, hostname } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
- import { DEFAULT_RELAY_URL, stringValue } from "agent-relay-sdk";
6
- import type { WorkspaceMetadata } from "agent-relay-sdk";
5
+ import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "agent-relay-sdk";
6
+ import type { SettingEntry, WorkspaceMetadata } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
8
  import type { ProviderConfig } from "./adapter";
9
9
 
@@ -36,6 +36,7 @@ export function defaultProviderConfig(provider: string): ProviderConfig {
36
36
  defaultApprovalMode: "guarded",
37
37
  defaultTags: [],
38
38
  chatCaptureMode: "final",
39
+ reasoningCapture: true,
39
40
  headless: {
40
41
  tmuxPrefix: `${provider}-relay`,
41
42
  shutdownTimeoutMs: 10_000,
@@ -67,6 +68,7 @@ export function loadProviderConfig(provider: string, home = agentRelayHome()): L
67
68
  defaultApprovalMode: stringValue(raw.defaultApprovalMode) ?? defaults.defaultApprovalMode,
68
69
  defaultTags: stringArray(raw.defaultTags) ?? defaults.defaultTags,
69
70
  chatCaptureMode: enumValue(raw.chatCaptureMode, ["final", "full"]) ?? defaults.chatCaptureMode,
71
+ reasoningCapture: booleanValue(raw.reasoningCapture) ?? defaults.reasoningCapture,
70
72
  headless: {
71
73
  tmuxPrefix: stringValue(recordValue(raw.headless).tmuxPrefix) ?? defaults.headless.tmuxPrefix,
72
74
  shutdownTimeoutMs: positiveInteger(recordValue(raw.headless).shutdownTimeoutMs) ?? defaults.headless.shutdownTimeoutMs,
@@ -74,6 +76,24 @@ export function loadProviderConfig(provider: string, home = agentRelayHome()): L
74
76
  };
75
77
  }
76
78
 
79
+ export async function loadProviderConfigFromRelay(
80
+ provider: string,
81
+ opts: { relayUrl: string; token?: string; home?: string; host?: string },
82
+ ): Promise<LoadedProviderConfig> {
83
+ const host = opts.host ?? hostname();
84
+ const home = opts.home ?? agentRelayHome();
85
+ const local = loadProviderConfig(provider, home);
86
+ const key = `${host}/${provider}`;
87
+
88
+ try {
89
+ const fetched = await fetchProviderConfig(provider, opts.relayUrl, opts.token, key);
90
+ if (fetched) return fetched;
91
+ } catch (error) {
92
+ console.warn(`[agent-relay] provider config relay read failed (${errMessage(error)}); using local fallback ${local.path}.`);
93
+ }
94
+ return local;
95
+ }
96
+
77
97
  export function writeProviderConfig(provider: string, config: ProviderConfig, home = agentRelayHome()): LoadedProviderConfig {
78
98
  const dir = providersDir(home);
79
99
  mkdirSync(dir, { recursive: true });
@@ -93,6 +113,7 @@ export function providerConfigPublic(config: LoadedProviderConfig): Record<strin
93
113
  defaultApprovalMode: config.defaultApprovalMode,
94
114
  defaultTags: config.defaultTags,
95
115
  chatCaptureMode: config.chatCaptureMode,
116
+ reasoningCapture: config.reasoningCapture,
96
117
  headless: config.headless,
97
118
  };
98
119
  }
@@ -156,6 +177,10 @@ function enumValue<T extends string>(value: unknown, allowed: T[]): T | undefine
156
177
  return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined;
157
178
  }
158
179
 
180
+ function booleanValue(value: unknown): boolean | undefined {
181
+ return typeof value === "boolean" ? value : undefined;
182
+ }
183
+
159
184
  function stringArray(value: unknown): string[] | undefined {
160
185
  return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
161
186
  }
@@ -178,3 +203,42 @@ function maskEnv(env: Record<string, string>): Record<string, string> {
178
203
  }
179
204
  return result;
180
205
  }
206
+
207
+ async function fetchProviderConfig(
208
+ provider: string,
209
+ relayUrl: string,
210
+ token: string | undefined,
211
+ key: string,
212
+ ): Promise<LoadedProviderConfig | null> {
213
+ const url = new URL(`/api/settings/provider-config/${encodeURIComponent(key)}`, relayUrl);
214
+ const res = await fetch(url, {
215
+ headers: token ? { [RELAY_TOKEN_HEADER]: token } : undefined,
216
+ signal: AbortSignal.timeout(5_000),
217
+ });
218
+ if (res.status === 404) return null;
219
+ const body = await res.json().catch(() => null) as (Partial<SettingEntry> & { error?: string }) | null;
220
+ if (!res.ok) throw new Error(body?.error ? `${res.status}: ${body.error}` : `HTTP ${res.status}`);
221
+ if (body?.version === 0) return null;
222
+ return providerConfigFromValue(provider, body?.value, `config:provider-config/${key}`);
223
+ }
224
+
225
+ function providerConfigFromValue(provider: string, value: unknown, path: string): LoadedProviderConfig {
226
+ const raw = recordValue(value);
227
+ const defaults = defaultProviderConfig(provider);
228
+ return {
229
+ path,
230
+ command: stringValue(raw.command) ?? defaults.command,
231
+ defaultArgs: stringArray(raw.defaultArgs) ?? defaults.defaultArgs,
232
+ env: stringRecord(raw.env) ?? defaults.env,
233
+ pluginDirs: stringArray(raw.pluginDirs) ?? defaults.pluginDirs,
234
+ defaultCapabilities: stringArray(raw.defaultCapabilities) ?? defaults.defaultCapabilities,
235
+ defaultApprovalMode: stringValue(raw.defaultApprovalMode) ?? defaults.defaultApprovalMode,
236
+ defaultTags: stringArray(raw.defaultTags) ?? defaults.defaultTags,
237
+ chatCaptureMode: enumValue(raw.chatCaptureMode, ["final", "full"]) ?? defaults.chatCaptureMode,
238
+ reasoningCapture: booleanValue(raw.reasoningCapture) ?? defaults.reasoningCapture,
239
+ headless: {
240
+ tmuxPrefix: stringValue(recordValue(raw.headless).tmuxPrefix) ?? defaults.headless.tmuxPrefix,
241
+ shutdownTimeoutMs: positiveInteger(recordValue(raw.headless).shutdownTimeoutMs) ?? defaults.headless.shutdownTimeoutMs,
242
+ },
243
+ };
244
+ }
@@ -16,7 +16,7 @@ import { createCommand } from "./commands-db";
16
16
  import { cleanEnum, cleanString, cleanStringArray, optionalEnum } from "./validation";
17
17
  import { getAgentProfile, getSpawnPolicy } from "./config-store";
18
18
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
19
- import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
19
+ import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
20
20
  import { errMessage, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
21
21
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
22
22
  import type {
@@ -38,7 +38,6 @@ import type {
38
38
  TaskSeverity,
39
39
  UpdateAutomationInput,
40
40
  } from "./types";
41
-
42
41
  const AUTOMATION_KIND = "scheduled_task";
43
42
  const DEFAULT_TIMEZONE = "UTC";
44
43
  const DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
@@ -200,11 +199,11 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
200
199
  }
201
200
  const provider = optionalEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
202
201
  const model = cleanString(input.model, "targetPolicy.model", { max: 120 });
203
- const effort = input.effort === undefined || input.effort === null ? undefined : cleanEnum(input.effort, "targetPolicy.effort", VALID_EFFORTS) as ProviderEffort;
202
+ const effort = cleanString(input.effort, "targetPolicy.effort", { max: 120 }) as ProviderEffort | undefined;
204
203
  const profile = cleanString(input.profile, "targetPolicy.profile", { max: 120 });
205
204
  if (profile && !getAgentProfile(profile)) throw new ValidationError("agent profile not found");
206
205
  try {
207
- resolveProviderSelection({ provider, model, effort });
206
+ resolveProviderSelection({ provider, model, effort }, effectiveProviderCatalogList());
208
207
  } catch (error) {
209
208
  throw new ValidationError(errMessage(error));
210
209
  }
@@ -4,7 +4,7 @@ import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from
4
4
  import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
5
5
  import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
6
6
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
7
- import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
7
+ import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES } from "agent-relay-sdk"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
8
8
  import type {
9
9
  AgentProfile,
10
10
  AgentProfileBase,
@@ -23,7 +23,6 @@ import type {
23
23
  WorkspaceConfig,
24
24
  WorkspaceLandingPolicy,
25
25
  } from "./types";
26
-
27
26
  const CONFIG_HISTORY_LIMIT = 50;
28
27
  const SPAWN_POLICY_NAMESPACE = "spawn-policy";
29
28
  const AGENT_PROFILE_NAMESPACE = "agent-profile";
@@ -342,7 +341,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
342
341
  workspaceMode: value.workspaceMode === undefined || value.workspaceMode === null ? "inherit" : cleanEnum(value.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
343
342
  rig: cleanString(value.rig, "rig", { max: 120 }),
344
343
  model: cleanString(value.model, "model", { max: 120 }),
345
- effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
344
+ effort: cleanString(value.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
346
345
  profile: cleanString(value.profile, "profile", { max: 120 }),
347
346
  providerArgs: cleanStringArray(value.providerArgs, "providerArgs", { itemMax: 120, maxItems: 100 }) ?? [],
348
347
  prompt: cleanString(value.prompt, "prompt", { max: 16_000 }),
@@ -356,7 +355,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
356
355
  backoff: cleanBackoff(value.backoff),
357
356
  };
358
357
  try {
359
- resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
358
+ resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort }, effectiveProviderCatalogList());
360
359
  } catch (error) {
361
360
  throw new ValidationError(errMessage(error));
362
361
  }
@@ -408,7 +407,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
408
407
  enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
409
408
  provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
410
409
  model: cleanString(value.model, "model", { max: 120 }),
411
- effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
410
+ effort: cleanString(value.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
412
411
  permissionMode: (value.permissionMode === undefined || value.permissionMode === null
413
412
  ? "open"
414
413
  : cleanEnum(value.permissionMode, "permissionMode", VALID_PERMISSION_MODES)) as SpawnApprovalMode,
@@ -419,7 +418,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
419
418
  // Reject a provider/model/effort combo the catalog can't resolve before it ever
420
419
  // reaches a spawn (same guard as spawn policies).
421
420
  try {
422
- resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort });
421
+ resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort }, effectiveProviderCatalogList());
423
422
  } catch (error) {
424
423
  throw new ValidationError(errMessage(error));
425
424
  }
package/src/connectors.ts CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, ConnectorSummary } from "./types";
5
5
  import { ValidationError } from "./db";
6
6
  import { isRecord } from "agent-relay-sdk";
7
+ import { registerConnectorConfigSetting, validateConnectorConfig } from "./settings/modules";
7
8
 
8
9
  const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
9
10
  const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
@@ -99,6 +100,7 @@ function stringArray(value: unknown, field: string): string[] {
99
100
 
100
101
  export function registerConnectorManifest(manifest: ConnectorManifest, opts: { config?: Record<string, unknown>; state?: Record<string, unknown> } = {}): ConnectorSummary {
101
102
  const normalized = normalizeConnectorManifest(manifest);
103
+ registerConnectorConfigSetting(normalized, { defaults: opts.config });
102
104
  const dir = connectorDir(normalized.id);
103
105
  mkdirSync(dir, { recursive: true, mode: 0o700 });
104
106
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(normalized, null, 2) + "\n", { mode: 0o600 });
@@ -165,6 +167,9 @@ export function readConnectorConfig(id: string): Record<string, unknown> {
165
167
 
166
168
  export function writeConnectorConfig(id: string, config: Record<string, unknown>): Record<string, unknown> {
167
169
  if (!isRecord(config)) throw new ValidationError("connector config must be an object");
170
+ const connector = getConnector(id);
171
+ if (!connector) throw new ValidationError(`connector ${id} not found`);
172
+ validateConnectorConfig(connector.manifest, config);
168
173
  const path = join(connectorDir(id), "config.json");
169
174
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
170
175
  writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
@@ -394,6 +394,12 @@ export function applyMigrations(): void {
394
394
  "UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
395
395
  ).run(Date.now(), CLAIM_LEASE_MS);
396
396
 
397
+ const providerModelOverrideCols = getDb().query("PRAGMA table_info(provider_model_overrides)").all() as any[];
398
+ const providerModelOverrideColNames = providerModelOverrideCols.map((c: any) => c.name);
399
+ if (!providerModelOverrideColNames.includes("disabled")) {
400
+ getDb().run("ALTER TABLE provider_model_overrides ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0");
401
+ }
402
+
397
403
  // Migration: orchestrators.api_url
398
404
  const orchCols = getDb().query("PRAGMA table_info(orchestrators)").all() as any[];
399
405
  const orchColNames = orchCols.map((c: any) => c.name);
package/src/db/schema.ts CHANGED
@@ -829,7 +829,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
829
829
  provider TEXT NOT NULL,
830
830
  alias TEXT NOT NULL,
831
831
  entry TEXT NOT NULL DEFAULT '{}',
832
- deleted INTEGER NOT NULL DEFAULT 0,
832
+ deleted INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
833
833
  updated_at INTEGER NOT NULL,
834
834
  updated_by TEXT,
835
835
  PRIMARY KEY (provider, alias)
package/src/index.ts CHANGED
@@ -268,13 +268,16 @@ export function createFetchHandler(
268
268
  if (!publicPaths.includes(url.pathname)) {
269
269
  const integrationAuth = getIntegrationAuth(req);
270
270
  const componentAuth = getComponentAuth(req);
271
+ const handlerOwnsScopedAuth = url.pathname === "/api/tokens/me" ||
272
+ url.pathname === "/api/settings" ||
273
+ url.pathname.startsWith("/api/settings/");
271
274
  if (!isAuthorized(req)) {
272
275
  if (!integrationAuth) {
273
276
  return unauthorized(req);
274
277
  }
275
- if (!isScopedRequestAuthorized(req)) return forbidden(req);
278
+ if (!handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
276
279
  }
277
- if (componentAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
280
+ if (componentAuth && !handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
278
281
  }
279
282
  const response = await matched.handler(req, matched.params);
280
283
  applyCors(req, response);
@@ -0,0 +1,9 @@
1
+ import { VALID_EFFORTS, isRecord } from "agent-relay-sdk";
2
+ import { effectiveProviderEffortLevels } from "./provider-catalog-store";
3
+
4
+ export function providerCatalogToolInputSchema(name: string, inputSchema: Record<string, unknown>): Record<string, unknown> {
5
+ if (name !== "relay_spawn_agent") return inputSchema;
6
+ const efforts = effectiveProviderEffortLevels();
7
+ const properties = isRecord(inputSchema.properties) ? inputSchema.properties : {};
8
+ return { ...inputSchema, properties: { ...properties, effort: { type: "string", enum: efforts.length ? efforts : VALID_EFFORTS } } };
9
+ }
package/src/mcp.ts CHANGED
@@ -52,7 +52,7 @@ import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
52
52
  import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
53
53
  import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "./mcp-merge-tools";
54
54
  import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
55
- import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
55
+ import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action"; import { providerCatalogToolInputSchema } from "./mcp-provider-catalog";
56
56
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
57
57
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
58
58
  import { AUTO_MERGE_POLICIES } from "./workspace-merge";
@@ -869,7 +869,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
869
869
  orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
870
870
  cwd: optionalString(args.cwd, "cwd", 500),
871
871
  model: optionalString(args.model, "model", 120),
872
- effort: optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
872
+ effort: optionalString(args.effort, "effort", 120) as ProviderEffort | undefined,
873
873
  approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
874
874
  workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
875
875
  lifecycle: (optionalEnum(args.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined) ?? "persistent",
@@ -1140,10 +1140,10 @@ function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string):
1140
1140
  function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
1141
1141
  return TOOLS
1142
1142
  .filter((tool) => hasAnyScope(auth, tool.requiredScopes))
1143
- .map(({ name, description, inputSchema, alwaysLoad }) => ({
1143
+ .map(({ name, description, alwaysLoad, ...tool }) => ({
1144
1144
  name,
1145
1145
  description,
1146
- inputSchema,
1146
+ inputSchema: providerCatalogToolInputSchema(name, tool.inputSchema),
1147
1147
  ...(alwaysLoad ? { _meta: { "anthropic/alwaysLoad": true } } : {}),
1148
1148
  }));
1149
1149
  }
@@ -13,6 +13,7 @@ interface ProviderModelOverrideRow {
13
13
  alias: string;
14
14
  entry: string;
15
15
  deleted: number;
16
+ disabled: number;
16
17
  updated_at: number;
17
18
  updated_by?: string | null;
18
19
  }
@@ -22,6 +23,7 @@ interface ProviderModelOverride {
22
23
  alias: string;
23
24
  entry: ProviderModelCatalogEntry;
24
25
  deleted: boolean;
26
+ disabled: boolean;
25
27
  updatedAt: number;
26
28
  updatedBy?: string;
27
29
  }
@@ -31,6 +33,7 @@ interface ProviderModelOverrideInput {
31
33
  alias: string;
32
34
  entry: ProviderModelCatalogEntry;
33
35
  deleted?: boolean;
36
+ disabled?: boolean;
34
37
  updatedBy?: string;
35
38
  }
36
39
 
@@ -45,11 +48,12 @@ export function upsertProviderModelOverride(input: ProviderModelOverrideInput):
45
48
  const normalized = normalizeOverrideInput(input);
46
49
  const now = Date.now();
47
50
  getDb().query(`
48
- INSERT INTO provider_model_overrides (provider, alias, entry, deleted, updated_at, updated_by)
49
- VALUES (?, ?, ?, ?, ?, ?)
51
+ INSERT INTO provider_model_overrides (provider, alias, entry, deleted, disabled, updated_at, updated_by)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?)
50
53
  ON CONFLICT(provider, alias) DO UPDATE SET
51
54
  entry = excluded.entry,
52
55
  deleted = excluded.deleted,
56
+ disabled = excluded.disabled,
53
57
  updated_at = excluded.updated_at,
54
58
  updated_by = excluded.updated_by
55
59
  `).run(
@@ -57,6 +61,7 @@ export function upsertProviderModelOverride(input: ProviderModelOverrideInput):
57
61
  normalized.alias,
58
62
  JSON.stringify(normalized.entry),
59
63
  normalized.deleted ? 1 : 0,
64
+ normalized.disabled ? 1 : 0,
60
65
  now,
61
66
  normalized.updatedBy ?? null,
62
67
  );
@@ -66,6 +71,7 @@ export function upsertProviderModelOverride(input: ProviderModelOverrideInput):
66
71
  alias: normalized.alias,
67
72
  entry: normalized.entry,
68
73
  deleted: Boolean(normalized.deleted),
74
+ disabled: Boolean(normalized.disabled),
69
75
  updatedAt: now,
70
76
  updatedBy: normalized.updatedBy,
71
77
  };
@@ -83,10 +89,26 @@ export function removeProviderModelOverride(provider: SpawnProvider, alias: stri
83
89
  });
84
90
  }
85
91
 
92
+ export function setProviderModelDisabled(provider: SpawnProvider, alias: string, disabled: boolean, updatedBy?: string): ProviderModelOverride {
93
+ const existing = findModelEntry(provider, alias);
94
+ if (!existing) throw new ValidationError(`unknown ${provider} model: ${alias}`);
95
+ return upsertProviderModelOverride({
96
+ provider,
97
+ alias,
98
+ entry: existing,
99
+ disabled,
100
+ updatedBy,
101
+ });
102
+ }
103
+
86
104
  export function effectiveProviderCatalogList(): ProviderCatalogEntry[] {
87
105
  return mergeProviderCatalog(providerCatalogList(), listProviderModelOverrides());
88
106
  }
89
107
 
108
+ export function effectiveProviderEffortLevels(): string[] {
109
+ return [...new Set(effectiveProviderCatalogList().flatMap((provider) => provider.models.flatMap((model) => model.efforts)))].sort();
110
+ }
111
+
90
112
  export function mergeProviderCatalog(
91
113
  baseCatalog: ProviderCatalogEntry[],
92
114
  overrides: ProviderModelOverride[],
@@ -102,7 +124,11 @@ export function mergeProviderCatalog(
102
124
  if (existingIndex >= 0) providerEntry.models.splice(existingIndex, 1);
103
125
  continue;
104
126
  }
105
- const nextEntry = cloneModelEntry({ ...override.entry, alias: override.alias });
127
+ const nextEntry = cloneModelEntry({
128
+ ...override.entry,
129
+ alias: override.alias,
130
+ ...(override.disabled ? { disabled: true } : { disabled: undefined }),
131
+ });
106
132
  if (existingIndex >= 0) providerEntry.models[existingIndex] = nextEntry;
107
133
  else providerEntry.models.push(nextEntry);
108
134
  }
@@ -118,10 +144,12 @@ function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderMode
118
144
  if (!input.entry.label?.trim()) throw new ValidationError("model label is required");
119
145
  if (!input.entry.providerModel?.trim()) throw new ValidationError("provider model is required");
120
146
  if (!Array.isArray(input.entry.efforts)) throw new ValidationError("model efforts must be an array");
147
+ const entry = cloneModelEntry({ ...input.entry, alias });
148
+ delete entry.disabled;
121
149
  return {
122
150
  ...input,
123
151
  alias,
124
- entry: cloneModelEntry({ ...input.entry, alias }),
152
+ entry,
125
153
  updatedBy: input.updatedBy?.trim() || undefined,
126
154
  };
127
155
  }
@@ -134,6 +162,7 @@ function rowToOverride(row: ProviderModelOverrideRow): ProviderModelOverride | u
134
162
  alias: row.alias,
135
163
  entry: cloneModelEntry({ ...parsed, alias: row.alias }),
136
164
  deleted: row.deleted === 1,
165
+ disabled: row.disabled === 1,
137
166
  updatedAt: row.updated_at,
138
167
  updatedBy: row.updated_by ?? undefined,
139
168
  };
@@ -0,0 +1,216 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ValidationError, listOrchestrators } from "./db";
5
+ import { getConfig, listConfig, setConfig } from "./config-store";
6
+ import { cleanString, cleanStringArray, optionalEnum } from "./validation";
7
+ import { emitConfigChanged } from "./sse";
8
+ import {
9
+ PROVIDER_CONFIG_NAMESPACE,
10
+ validateSettingValue,
11
+ } from "./settings/registry";
12
+ import {
13
+ getProviderSettingsDeclaration,
14
+ listProviderSettingsDeclarations,
15
+ providerConfigDefaults,
16
+ registerDeclaredProviderConfigSetting,
17
+ } from "./settings/modules";
18
+ import { defaultProviderConfig, loadProviderConfig as loadLocalProviderConfig } from "../runner/src/config";
19
+ import { SPAWN_PROVIDERS, isRecord, type ConfigEntry, type SpawnProvider } from "agent-relay-sdk";
20
+ import type { ProviderConfig } from "../runner/src/adapter";
21
+
22
+ export const VALID_PROVIDER_CONFIGS = SPAWN_PROVIDERS;
23
+
24
+ type ProviderConfigProvider = string;
25
+ type ProviderConfigEntry = ConfigEntry<ProviderConfig>;
26
+
27
+ export function defaultProviderConfigHost(): string {
28
+ return hostname();
29
+ }
30
+
31
+ export function providerConfigKey(host: string, provider: string): string {
32
+ return `${normalizeProviderConfigHost(host)}/${normalizeProviderConfigProvider(provider)}`;
33
+ }
34
+
35
+ export function parseProviderConfigKey(key: string): { host: string; provider: ProviderConfigProvider } | null {
36
+ const slash = key.lastIndexOf("/");
37
+ if (slash <= 0 || slash === key.length - 1) return null;
38
+ const host = cleanString(key.slice(0, slash), "host", { required: true, max: 120 })!;
39
+ const provider = cleanString(key.slice(slash + 1), "provider", { required: true, max: 80 })!;
40
+ return getProviderSettingsDeclaration(provider) ? { host, provider } : null;
41
+ }
42
+
43
+ export function ensureProviderConfigDescriptors(extraKeys: string[] = []): void {
44
+ const keys = new Set<string>();
45
+ const providerDeclarations = listProviderSettingsDeclarations();
46
+ for (const entry of listConfig(PROVIDER_CONFIG_NAMESPACE)) keys.add(entry.key);
47
+ for (const orchestrator of listOrchestrators()) {
48
+ if (!orchestrator.hostname) continue;
49
+ for (const declaration of providerDeclarations) keys.add(providerConfigKey(orchestrator.hostname, declaration.provider));
50
+ }
51
+ for (const declaration of providerDeclarations) keys.add(providerConfigKey(defaultProviderConfigHost(), declaration.provider));
52
+ for (const key of extraKeys) keys.add(key);
53
+
54
+ for (const key of keys) {
55
+ const parsed = parseProviderConfigKey(key);
56
+ if (!parsed) continue;
57
+ registerDeclaredProviderConfigSetting({
58
+ host: parsed.host,
59
+ provider: parsed.provider,
60
+ });
61
+ }
62
+ }
63
+
64
+ export function getProviderConfigEntry(provider: string, host = defaultProviderConfigHost()): ProviderConfigEntry {
65
+ const cleanProvider = normalizeProviderConfigProvider(provider);
66
+ const cleanHost = normalizeProviderConfigHost(host);
67
+ const key = providerConfigKey(cleanHost, cleanProvider);
68
+ ensureProviderConfigDescriptors([key]);
69
+ const entry = getConfig<ProviderConfig>(PROVIDER_CONFIG_NAMESPACE, key);
70
+ if (entry) return { ...entry, value: mergeProviderConfig(cleanProvider, entry.value) };
71
+ return {
72
+ namespace: PROVIDER_CONFIG_NAMESPACE,
73
+ key,
74
+ value: providerConfigDefaults(cleanProvider),
75
+ version: 0,
76
+ updatedAt: "default",
77
+ updatedBy: "system",
78
+ };
79
+ }
80
+
81
+ export function listProviderConfigEntries(host = defaultProviderConfigHost()): Record<SpawnProvider, ProviderConfigEntry> {
82
+ return Object.fromEntries(
83
+ VALID_PROVIDER_CONFIGS.map((provider) => [provider, getProviderConfigEntry(provider, host)]),
84
+ ) as Record<SpawnProvider, ProviderConfigEntry>;
85
+ }
86
+
87
+ export function setProviderConfig(
88
+ provider: string,
89
+ value: unknown,
90
+ opts: { host?: string; updatedBy?: string } = {},
91
+ ): ProviderConfigEntry {
92
+ const cleanProvider = normalizeProviderConfigProvider(provider);
93
+ const host = normalizeProviderConfigHost(opts.host ?? defaultProviderConfigHost());
94
+ const normalized = cleanProviderConfig(cleanProvider, value);
95
+ const key = providerConfigKey(host, cleanProvider);
96
+ ensureProviderConfigDescriptors([key]);
97
+ const validation = validateSettingValue(PROVIDER_CONFIG_NAMESPACE, key, normalized);
98
+ if (!validation.valid) throw new ValidationError(validation.errors.join("; "));
99
+ return setConfig(PROVIDER_CONFIG_NAMESPACE, key, normalized, opts.updatedBy) as ProviderConfigEntry;
100
+ }
101
+
102
+ export function providerConfigPublic(entry: ProviderConfigEntry, opts: { maskEnv?: boolean } = {}): Record<string, unknown> {
103
+ const parsed = parseProviderConfigKey(entry.key);
104
+ return {
105
+ namespace: entry.namespace,
106
+ key: entry.key,
107
+ path: `config:${entry.namespace}/${entry.key}`,
108
+ host: parsed?.host,
109
+ provider: parsed?.provider,
110
+ version: entry.version,
111
+ updatedAt: entry.updatedAt,
112
+ updatedBy: entry.updatedBy,
113
+ command: entry.value.command,
114
+ defaultArgs: entry.value.defaultArgs,
115
+ env: opts.maskEnv === false ? entry.value.env : maskEnv(entry.value.env),
116
+ pluginDirs: entry.value.pluginDirs,
117
+ defaultCapabilities: entry.value.defaultCapabilities,
118
+ defaultApprovalMode: entry.value.defaultApprovalMode,
119
+ defaultTags: entry.value.defaultTags,
120
+ chatCaptureMode: entry.value.chatCaptureMode,
121
+ reasoningCapture: entry.value.reasoningCapture,
122
+ headless: entry.value.headless,
123
+ };
124
+ }
125
+
126
+ export function migrateLocalProviderConfigs(home = agentRelayHome(), host = defaultProviderConfigHost()): ProviderConfigEntry[] {
127
+ const dir = join(home, "providers");
128
+ if (!existsSync(dir)) return [];
129
+ const migrated: ProviderConfigEntry[] = [];
130
+ for (const file of readdirSync(dir)) {
131
+ const provider = file.endsWith(".json") ? file.slice(0, -".json".length) : "";
132
+ if (!VALID_PROVIDER_CONFIGS.includes(provider as SpawnProvider)) continue;
133
+ const key = providerConfigKey(host, provider);
134
+ if (getConfig(PROVIDER_CONFIG_NAMESPACE, key)) continue;
135
+ const local = loadLocalProviderConfig(provider, home);
136
+ const entry = setProviderConfig(provider, local, { host, updatedBy: `migration:${host}` });
137
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
138
+ migrated.push(entry);
139
+ }
140
+ return migrated;
141
+ }
142
+
143
+ function normalizeProviderConfigHost(host: string): string {
144
+ return cleanString(host, "host", { required: true, max: 120 })!;
145
+ }
146
+
147
+ function normalizeProviderConfigProvider(provider: string): ProviderConfigProvider {
148
+ const clean = cleanString(provider, "provider", { required: true, max: 80 })!;
149
+ if (!getProviderSettingsDeclaration(clean)) {
150
+ throw new ValidationError(`provider must be one of: ${listProviderSettingsDeclarations().map((d) => d.provider).join(", ")}`);
151
+ }
152
+ return clean;
153
+ }
154
+
155
+ function cleanProviderConfig(provider: string, value: unknown): ProviderConfig {
156
+ if (!isRecord(value)) throw new ValidationError("provider config body must be an object");
157
+ const defaults = providerConfigDefaults(provider);
158
+ const headless = isRecord(value.headless) ? value.headless : {};
159
+ return {
160
+ command: cleanString(value.command, "command", { required: true, max: 500 })!,
161
+ defaultArgs: cleanStringArray(value.defaultArgs, "defaultArgs", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultArgs,
162
+ env: cleanEnvRecord(value.env),
163
+ pluginDirs: cleanStringArray(value.pluginDirs, "pluginDirs", { itemMax: 80, maxItems: 50 }) ?? defaults.pluginDirs,
164
+ defaultCapabilities: cleanStringArray(value.defaultCapabilities, "defaultCapabilities", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultCapabilities,
165
+ defaultApprovalMode: cleanString(value.defaultApprovalMode, "defaultApprovalMode", { max: 80 }) ?? defaults.defaultApprovalMode,
166
+ defaultTags: cleanStringArray(value.defaultTags, "defaultTags", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultTags,
167
+ chatCaptureMode: (optionalEnum(value.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
168
+ reasoningCapture: value.reasoningCapture === undefined ? defaults.reasoningCapture ?? true : cleanBoolean(value.reasoningCapture, "reasoningCapture"),
169
+ headless: {
170
+ tmuxPrefix: cleanString(headless.tmuxPrefix, "headless.tmuxPrefix", { max: 120 }) ?? defaults.headless.tmuxPrefix,
171
+ shutdownTimeoutMs: cleanPositiveInt(headless.shutdownTimeoutMs, "headless.shutdownTimeoutMs") ?? defaults.headless.shutdownTimeoutMs,
172
+ },
173
+ };
174
+ }
175
+
176
+ function mergeProviderConfig(provider: string, value: Partial<ProviderConfig>): ProviderConfig {
177
+ const defaults = providerConfigDefaults(provider);
178
+ return cleanProviderConfig(provider, { ...defaults, ...value, headless: { ...defaults.headless, ...value.headless } });
179
+ }
180
+
181
+ function cleanEnvRecord(value: unknown): Record<string, string> {
182
+ if (value === undefined) return {};
183
+ if (!isRecord(value)) throw new ValidationError("env must be an object");
184
+ const env: Record<string, string> = {};
185
+ for (const [key, item] of Object.entries(value)) {
186
+ const cleanKey = cleanString(key, "env key", { required: true, max: 120 })!;
187
+ if (typeof item !== "string") throw new ValidationError(`env.${cleanKey} must be a string`);
188
+ env[cleanKey] = item;
189
+ }
190
+ return env;
191
+ }
192
+
193
+ function cleanBoolean(value: unknown, field: string): boolean {
194
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
195
+ return value;
196
+ }
197
+
198
+ function cleanPositiveInt(value: unknown, field: string): number | undefined {
199
+ if (value === undefined) return undefined;
200
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
201
+ throw new ValidationError(`${field} must be a positive integer`);
202
+ }
203
+ return value;
204
+ }
205
+
206
+ function maskEnv(env: Record<string, string>): Record<string, string> {
207
+ const result: Record<string, string> = {};
208
+ for (const [key, value] of Object.entries(env)) {
209
+ result[key] = /token|secret|key|password/i.test(key) && !value.startsWith("$env:") ? "********" : value;
210
+ }
211
+ return result;
212
+ }
213
+
214
+ function agentRelayHome(): string {
215
+ return process.env.AGENT_RELAY_HOME || join(process.env.HOME || ".", ".agent-relay");
216
+ }
@@ -2,6 +2,7 @@ import { createCommand } from "./commands-db";
2
2
  import { listOrchestrators } from "./db";
3
3
  import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
4
4
  import { getRecipe } from "./recipe-loader";
5
+ import { effectiveProviderCatalogList } from "./provider-catalog-store";
5
6
  import {
6
7
  createRecipeInstance,
7
8
  getRecipeInstance,
@@ -56,7 +57,7 @@ export function startRecipe(input: StartRecipeInput): StartRecipeResult {
56
57
  for (const agent of recipe.agents) {
57
58
  const count = agent.count ?? 1;
58
59
  for (let index = 0; index < count; index++) {
59
- const selection = resolveProviderSelection({ provider: agent.provider, model: agent.model, effort: agent.effort });
60
+ const selection = resolveProviderSelection({ provider: agent.provider, model: agent.model, effort: agent.effort }, effectiveProviderCatalogList());
60
61
  const label = count > 1 ? `${recipe.name}:${agent.role}:${index + 1}` : `${recipe.name}:${agent.role}`;
61
62
  const memoryTags = [...new Set([...(recipe.lifecycle?.memory?.memoryTags ?? []), ...(agent.memoryTags ?? [])])];
62
63
  const alwaysReload = recipe.lifecycle?.memory?.alwaysReload ?? [];