agent-relay-server 0.60.1 → 0.61.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 (157) 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/provider-config.ts +89 -41
  130. package/src/routes/settings.ts +127 -0
  131. package/src/routes/steward.ts +5 -5
  132. package/src/routes/tokens.ts +32 -1
  133. package/src/security.ts +34 -37
  134. package/src/settings/modules.ts +149 -0
  135. package/src/settings/registry.ts +397 -0
  136. package/src/spawn-command.ts +5 -1
  137. package/src/team-template-config.ts +2 -2
  138. package/src/token-db.ts +20 -0
  139. package/public/assets/agents-D201CuTR.js +0 -2
  140. package/public/assets/branch-state-badge-BPXXLpQG.js +0 -2
  141. package/public/assets/card-BU6PvVHv.js +0 -2
  142. package/public/assets/chat-CHgPGHQD.js +0 -2
  143. package/public/assets/display-CketawEF.js +0 -3
  144. package/public/assets/dist-D41tzJYg.js +0 -2
  145. package/public/assets/dist-DUoZnlB2.js +0 -2
  146. package/public/assets/dist-De2oguBm.js +0 -2
  147. package/public/assets/index-D7js3XlH.js +0 -21
  148. package/public/assets/index-DaNOQJci.css +0 -2
  149. package/public/assets/lucide-react-H_-8G79A.js +0 -9
  150. package/public/assets/lucide-react-H_-8G79A.js.map +0 -1
  151. package/public/assets/memory-SLycz2gC.js +0 -2
  152. package/public/assets/settings-CGcqyJ5L.js +0 -10
  153. package/public/assets/settings-CGcqyJ5L.js.map +0 -1
  154. package/public/assets/store-DXdfhiVf.js +0 -9
  155. package/public/assets/store-DXdfhiVf.js.map +0 -1
  156. package/public/assets/tasks-DWlKg218.js +0 -2
  157. package/public/assets/teams-DRchUP_p.js +0 -2
@@ -1,6 +1,7 @@
1
1
  import type { MemoryType, Recipe, RecipeAgent, RecipeMemoryPolicy } from "./types";
2
2
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
3
3
  import { errMessage, isRecord } from "agent-relay-sdk";
4
+ import { effectiveProviderCatalogList } from "./provider-catalog-store";
4
5
 
5
6
  class RecipeValidationError extends Error {}
6
7
 
@@ -50,7 +51,7 @@ function validateAgent(value: unknown): RecipeAgent {
50
51
  const model = optionalString(value.model, "agent.model");
51
52
  const effort = optionalEffort(value.effort, "agent.effort");
52
53
  try {
53
- resolveProviderSelection({ provider, model, effort });
54
+ resolveProviderSelection({ provider, model, effort }, effectiveProviderCatalogList());
54
55
  } catch (error) {
55
56
  throw new RecipeValidationError(errMessage(error));
56
57
  }
@@ -103,8 +104,8 @@ function optionalString(value: unknown, field: string): string | undefined {
103
104
 
104
105
  function optionalEffort(value: unknown, field: string): ProviderEffort | undefined {
105
106
  if (value === undefined || value === null) return undefined;
106
- if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") return value;
107
- throw new RecipeValidationError(`${field} must be low, medium, high, xhigh, or max`);
107
+ if (typeof value !== "string") throw new RecipeValidationError(`${field} must be a string`);
108
+ return value.trim() as ProviderEffort || undefined;
108
109
  }
109
110
 
110
111
  function optionalBoolean(value: unknown, field: string): boolean | undefined {
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
- import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
2
+ import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
3
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
4
  import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
5
5
  import { ValidationError, listAgents } from "../db";
@@ -109,7 +109,7 @@ export const postAgentSpawn: Handler = async (req) => {
109
109
  orchestratorId: cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 }),
110
110
  cwd: cleanString(parsed.body.cwd, "cwd", { max: 500 }),
111
111
  model: cleanString(parsed.body.model, "model", { max: 120 }),
112
- effort: optionalEnum(parsed.body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
112
+ effort: cleanString(parsed.body.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
113
113
  approvalMode: optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
114
114
  workspaceMode: optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
115
115
  label: cleanString(parsed.body.label, "label", { max: 120 }),
@@ -4,14 +4,14 @@ import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, p
4
4
  import { deleteAgentTerminalSession, postAgentAction, postAgentPermissionDecision, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
5
5
  import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
6
6
  import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
7
- import { deleteCommandById, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, postProviderConfigTestRoute, putProviderConfigRoute } from "./provider-config";
7
+ import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
8
8
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
9
9
  import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute, patchInboxThreadState, postChatHistoryImportRoute, putInboxDraft } from "./inbox";
10
10
  import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
11
11
  import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRoute, getMessageStatusRoute, getMessageThread, getMessages, getQueuedMessagesRoute, patchMessage, postClaimMessage, postMessage, postMessageDeliveryAction, postMessageDeliveryAttempt, postMessageReaction, postRenewMessageClaim, postSystemBroadcast } from "./messages";
12
12
  import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
13
13
  import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
14
- import { deleteTokenProfileRoute, getTokenById, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
14
+ import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
15
15
  import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim } from "./workspaces";
16
16
  import { error, type Handler } from "./_shared";
17
17
  import { getActivityEvents, postActivityEvent } from "./activity";
@@ -30,6 +30,7 @@ import { getPlanByIdRoute, getPlansRoute } from "./plans";
30
30
  import { getBlackboardEntryRoute, getBlackboardRoute } from "./blackboard";
31
31
  import { getProjectEntriesRoute, getProjectEntryRoute, getProjectRoute, getProjectsRoute } from "./projects";
32
32
  import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
33
+ import { getSettingRoute, getSettingsNamespaceRoute, getSettingsRoute, putSettingRoute, putSettingsDefaultRoute } from "./settings";
33
34
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
34
35
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
35
36
  import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
@@ -168,6 +169,7 @@ const routes: Route[] = [
168
169
  route("GET", "/api/recipes/:name", getRecipeByName),
169
170
  route("GET", "/api/tokens", getTokens),
170
171
  route("POST", "/api/tokens", postToken),
172
+ route("GET", "/api/tokens/me", getTokenMe),
171
173
  route("POST", "/api/runtime-tokens/renew", postRuntimeTokenRenew),
172
174
  route("POST", "/api/runtime-tokens/interactive-runner", postInteractiveRunnerRuntimeToken),
173
175
  route("POST", "/api/runtime-tokens/mcp-client", postMcpRuntimeToken),
@@ -196,6 +198,11 @@ const routes: Route[] = [
196
198
  route("GET", "/api/insights/observations", getInsightsObservationsRoute),
197
199
  route("POST", "/api/insights/observations", postInsightsObservationRoute),
198
200
  route("POST", "/api/continuation-archives", postContinuationArchiveRoute),
201
+ route("GET", "/api/settings", getSettingsRoute),
202
+ route("GET", "/api/settings/:namespace", getSettingsNamespaceRoute),
203
+ route("GET", "/api/settings/:namespace/:key", getSettingRoute),
204
+ route("PUT", "/api/settings/:namespace", putSettingsDefaultRoute),
205
+ route("PUT", "/api/settings/:namespace/:key", putSettingRoute),
199
206
  route("GET", "/api/config/:namespace", getConfigNamespace),
200
207
  route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
201
208
  route("GET", "/api/config/:namespace/:key", getConfigKey),
@@ -213,6 +220,9 @@ const routes: Route[] = [
213
220
  route("GET", "/api/spawn-policy/:name/health", getSpawnPolicyHealth),
214
221
  route("GET", "/api/providers", getProvidersRoute),
215
222
  route("GET", "/api/providers/config", getProviderConfigsRoute),
223
+ route("PUT", "/api/providers/:provider/models/:alias", putProviderModelRoute),
224
+ route("PATCH", "/api/providers/:provider/models/:alias", patchProviderModelRoute),
225
+ route("DELETE", "/api/providers/:provider/models/:alias", deleteProviderModelRoute),
216
226
  route("GET", "/api/providers/:provider/config", getProviderConfigRoute),
217
227
  route("PUT", "/api/providers/:provider/config", putProviderConfigRoute),
218
228
  route("POST", "/api/providers/:provider/config/test", postProviderConfigTestRoute),
@@ -3,11 +3,12 @@ import { ValidationError } from "../db";
3
3
  import { cleanString } from "../validation";
4
4
  import { emitConfigChanged } from "../sse";
5
5
  import { error, json, parseBody, type Handler } from "./_shared";
6
- import { getInsightsConfigEntry, setInsightsConfig } from "../config-store";
6
+ import { getInsightsConfigEntry } from "../config-store";
7
7
  import { getObservationStats, listObservationProjects, listObservations, recordObservation } from "../insights-db";
8
+ import { getRegisteredSettingEntry, setRegisteredSettingValue } from "./settings";
8
9
  import { isRecord } from "agent-relay-sdk";
9
10
 
10
- export const getInsightsConfigRoute: Handler = () => json(getInsightsConfigEntry());
11
+ export const getInsightsConfigRoute: Handler = () => json(getRegisteredSettingEntry("insights", "default"));
11
12
 
12
13
  export const putInsightsConfigRoute: Handler = async (req) => {
13
14
  const parsed = await parseBody<unknown>(req);
@@ -17,7 +18,7 @@ export const putInsightsConfigRoute: Handler = async (req) => {
17
18
  ? parsed.body.value
18
19
  : parsed.body;
19
20
  const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
20
- const entry = setInsightsConfig(value, updatedBy);
21
+ const entry = setRegisteredSettingValue("insights", "default", value, updatedBy);
21
22
  emitConfigChanged(entry.namespace, entry.key, entry.version);
22
23
  return json(entry, entry.version === 1 ? 201 : 200);
23
24
  } catch (e) {
@@ -2,14 +2,13 @@
2
2
  import { ValidationError, listOrchestrators } from "../db";
3
3
  import { applyCommandToRecipe } from "../recipe-runner";
4
4
  import { cleanString, cleanStringArray, optionalEnum } from "../validation";
5
- import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../../runner/src/config";
6
5
  import { deleteCommand, getCommand } from "../commands-db";
7
- import { effectiveProviderCatalogList } from "../provider-catalog-store";
6
+ import { effectiveProviderCatalogList, removeProviderModelOverride, setProviderModelDisabled, upsertProviderModelOverride } from "../provider-catalog-store";
8
7
  import { emitCommand, error, json, parseBody, type Handler } from "./_shared";
9
- import { isRecord } from "agent-relay-sdk";
10
- import { type ProviderConfig } from "../../runner/src/adapter";
11
-
12
- const VALID_PROVIDER_CONFIGS = ["claude", "codex"] as const;
8
+ import { getProviderConfigEntry, listProviderConfigEntries, migrateLocalProviderConfigs, providerConfigPublic, setProviderConfig, VALID_PROVIDER_CONFIGS } from "../provider-config-store";
9
+ import { emitConfigChanged } from "../sse";
10
+ import { isRecord, type SpawnProvider } from "agent-relay-sdk";
11
+ import type { ProviderModelCatalogEntry } from "agent-relay-sdk/provider-catalog";
13
12
 
14
13
  export const getProvidersRoute: Handler = () => {
15
14
  return json({
@@ -25,17 +24,72 @@ export const getProvidersRoute: Handler = () => {
25
24
  });
26
25
  };
27
26
 
28
- export const getProviderConfigsRoute: Handler = () => {
27
+ export const getProviderConfigsRoute: Handler = (req) => {
28
+ const host = new URL(req.url).searchParams.get("host") ?? undefined;
29
+ migrateLocalProviderConfigs();
30
+ const entries = listProviderConfigEntries(host);
29
31
  return json(Object.fromEntries(VALID_PROVIDER_CONFIGS.map((provider) => {
30
- const config = loadProviderConfig(provider);
31
- return [provider, providerConfigPublic(config)];
32
+ return [provider, providerConfigPublic(entries[provider])];
32
33
  })));
33
34
  };
34
35
 
36
+ export const putProviderModelRoute: Handler = async (req, params) => {
37
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS) as SpawnProvider | undefined;
38
+ if (!provider) return error("provider required");
39
+ const alias = cleanString(params.alias, "alias", { required: true, max: 120 })!;
40
+ const parsed = await parseBody<unknown>(req);
41
+ if (!parsed.ok) return error(parsed.error, parsed.status);
42
+ try {
43
+ if (!isRecord(parsed.body)) return error("model body must be an object");
44
+ const entry = cleanProviderModelEntry(alias, parsed.body);
45
+ return json(upsertProviderModelOverride({
46
+ provider,
47
+ alias,
48
+ entry,
49
+ disabled: optionalBoolean(parsed.body.disabled, "disabled"),
50
+ updatedBy: cleanString(parsed.body.updatedBy, "updatedBy", { max: 120 }),
51
+ }));
52
+ } catch (e) {
53
+ if (e instanceof ValidationError) return error(e.message, 400);
54
+ throw e;
55
+ }
56
+ };
57
+
58
+ export const patchProviderModelRoute: Handler = async (req, params) => {
59
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS) as SpawnProvider | undefined;
60
+ if (!provider) return error("provider required");
61
+ const alias = cleanString(params.alias, "alias", { required: true, max: 120 })!;
62
+ const parsed = await parseBody<unknown>(req);
63
+ if (!parsed.ok) return error(parsed.error, parsed.status);
64
+ try {
65
+ if (!isRecord(parsed.body)) return error("model patch body must be an object");
66
+ if (parsed.body.disabled === undefined) throw new ValidationError("disabled required");
67
+ return json(setProviderModelDisabled(provider, alias, optionalBoolean(parsed.body.disabled, "disabled") ?? false, cleanString(parsed.body.updatedBy, "updatedBy", { max: 120 })));
68
+ } catch (e) {
69
+ if (e instanceof ValidationError) return error(e.message, 400);
70
+ throw e;
71
+ }
72
+ };
73
+
74
+ export const deleteProviderModelRoute: Handler = (_req, params) => {
75
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS) as SpawnProvider | undefined;
76
+ if (!provider) return error("provider required");
77
+ try {
78
+ const alias = cleanString(params.alias, "alias", { required: true, max: 120 })!;
79
+ return json(removeProviderModelOverride(provider, alias));
80
+ } catch (e) {
81
+ if (e instanceof ValidationError) return error(e.message, 400);
82
+ throw e;
83
+ }
84
+ };
85
+
35
86
  export const getProviderConfigRoute: Handler = (_req, params) => {
36
87
  const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
37
88
  if (!provider) return error("provider required");
38
- return json(providerConfigPublic(loadProviderConfig(provider)));
89
+ const host = new URL(_req.url).searchParams.get("host") ?? undefined;
90
+ const includeSecrets = new URL(_req.url).searchParams.get("includeSecrets") === "1";
91
+ migrateLocalProviderConfigs();
92
+ return json(providerConfigPublic(getProviderConfigEntry(provider, host), { maskEnv: !includeSecrets }));
39
93
  };
40
94
 
41
95
  export const putProviderConfigRoute: Handler = async (req, params) => {
@@ -45,23 +99,11 @@ export const putProviderConfigRoute: Handler = async (req, params) => {
45
99
  if (!parsed.ok) return error(parsed.error, parsed.status);
46
100
  try {
47
101
  if (!isRecord(parsed.body)) return error("provider config body must be an object");
48
- const defaults = defaultProviderConfig(provider);
49
- const headless = isRecord(parsed.body.headless) ? parsed.body.headless : {};
50
- const config: ProviderConfig = {
51
- command: cleanString(parsed.body.command, "command", { required: true, max: 500 })!,
52
- defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultArgs,
53
- env: cleanEnvRecord(parsed.body.env),
54
- pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs", { itemMax: 80, maxItems: 50 }) ?? defaults.pluginDirs,
55
- defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultCapabilities,
56
- defaultApprovalMode: cleanString(parsed.body.defaultApprovalMode, "defaultApprovalMode", { max: 80 }) ?? defaults.defaultApprovalMode,
57
- defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultTags,
58
- chatCaptureMode: (optionalEnum(parsed.body.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
59
- headless: {
60
- tmuxPrefix: cleanString(headless.tmuxPrefix, "headless.tmuxPrefix", { max: 120 }) ?? defaults.headless.tmuxPrefix,
61
- shutdownTimeoutMs: cleanPositiveInt(headless.shutdownTimeoutMs, "headless.shutdownTimeoutMs") ?? defaults.headless.shutdownTimeoutMs,
62
- },
63
- };
64
- return json(providerConfigPublic(writeProviderConfig(provider, config)));
102
+ const host = new URL(req.url).searchParams.get("host") ?? undefined;
103
+ const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
104
+ const entry = setProviderConfig(provider, parsed.body, { host, updatedBy });
105
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
106
+ return json(providerConfigPublic(entry), entry.version === 1 ? 201 : 200);
65
107
  } catch (e) {
66
108
  if (e instanceof ValidationError) return error(e.message, 400);
67
109
  throw e;
@@ -71,7 +113,8 @@ export const putProviderConfigRoute: Handler = async (req, params) => {
71
113
  export const postProviderConfigTestRoute: Handler = async (_req, params) => {
72
114
  const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
73
115
  if (!provider) return error("provider required");
74
- const config = loadProviderConfig(provider);
116
+ const host = new URL(_req.url).searchParams.get("host") ?? undefined;
117
+ const config = getProviderConfigEntry(provider, host).value;
75
118
  const proc = Bun.spawn(["bash", "-lc", `command -v "$1"`, "bash", config.command], {
76
119
  stdout: "pipe",
77
120
  stderr: "pipe",
@@ -82,22 +125,27 @@ export const postProviderConfigTestRoute: Handler = async (_req, params) => {
82
125
  new Response(proc.stderr).text(),
83
126
  ]);
84
127
  return json({ ok: exitCode === 0, command: config.command, path: stdout.trim(), error: stderr.trim() }, exitCode === 0 ? 200 : 422);
85
- };
128
+ }
86
129
 
87
- function cleanEnvRecord(value: unknown): Record<string, string> {
88
- if (value === undefined) return {};
89
- if (!isRecord(value)) throw new ValidationError("env must be an object");
90
- const env: Record<string, string> = {};
91
- for (const [key, item] of Object.entries(value)) {
92
- if (typeof item !== "string") throw new ValidationError(`env.${key} must be a string`);
93
- env[key] = item;
94
- }
95
- return env;
130
+ function cleanProviderModelEntry(alias: string, body: Record<string, unknown>): ProviderModelCatalogEntry {
131
+ const source = isRecord(body.entry) ? body.entry : body;
132
+ const efforts = cleanStringArray(source.efforts, "efforts", { itemMax: 120, maxItems: 50 }) ?? [];
133
+ const defaultEffort = cleanString(source.defaultEffort, "defaultEffort", { max: 120 });
134
+ if (defaultEffort && !efforts.includes(defaultEffort)) throw new ValidationError("defaultEffort must be included in efforts");
135
+ return {
136
+ alias,
137
+ label: cleanString(source.label, "label", { required: true, max: 120 })!,
138
+ providerModel: cleanString(source.providerModel, "providerModel", { required: true, max: 160 })!,
139
+ efforts,
140
+ ...(defaultEffort ? { defaultEffort } : {}),
141
+ ...(isRecord(source.limits) ? { limits: source.limits as ProviderModelCatalogEntry["limits"] } : {}),
142
+ ...(isRecord(source.capabilities) ? { capabilities: source.capabilities as unknown as ProviderModelCatalogEntry["capabilities"] } : {}),
143
+ };
96
144
  }
97
145
 
98
- function cleanPositiveInt(value: unknown, field: string): number | undefined {
99
- if (value === undefined) return undefined;
100
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
146
+ function optionalBoolean(value: unknown, field: string): boolean | undefined {
147
+ if (value === undefined || value === null) return undefined;
148
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
101
149
  return value;
102
150
  }
103
151
 
@@ -0,0 +1,127 @@
1
+ // Auto-split from routes.ts (#299). Domain: schema-driven settings.
2
+ import { ValidationError } from "../db";
3
+ import { getConfig, setConfig } from "../config-store";
4
+ import { ensureProviderConfigDescriptors, migrateLocalProviderConfigs } from "../provider-config-store";
5
+ import { getSettingDescriptor, listSettingDescriptors, validateSettingValue, PROVIDER_CONFIG_NAMESPACE } from "../settings/registry";
6
+ import { isRequestAuthorizedFor } from "../security";
7
+ import { cleanString } from "../validation";
8
+ import { emitConfigChanged } from "../sse";
9
+ import { error, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
10
+ import { isRecord, type ConfigEntry, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
11
+
12
+ type SettingEnvelope = SettingEntry;
13
+
14
+ export const getSettingsRoute: Handler = (req) => {
15
+ migrateLocalProviderConfigs();
16
+ ensureProviderConfigDescriptors();
17
+ return json({ settings: listVisibleSettings(req) });
18
+ };
19
+
20
+ export const getSettingsNamespaceRoute: Handler = (req, params) => {
21
+ const namespace = normalizeConfigPathParam(params.namespace, "namespace");
22
+ migrateLocalProviderConfigs();
23
+ ensureProviderConfigDescriptors();
24
+ return json({ settings: listVisibleSettings(req).filter((setting) => setting.namespace === namespace) });
25
+ };
26
+
27
+ export const getSettingRoute: Handler = (req, params) => {
28
+ const namespace = normalizeConfigPathParam(params.namespace, "namespace");
29
+ const key = normalizeConfigPathParam(params.key, "key");
30
+ migrateLocalProviderConfigs();
31
+ ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
32
+ const descriptor = getSettingDescriptor(namespace, key);
33
+ if (!descriptor) return error("setting not found", 404);
34
+ const readable = canAccessSetting(req, descriptor.scope.read);
35
+ if (!readable) return error("forbidden", 403);
36
+ return json(settingEnvelope(req, descriptor));
37
+ };
38
+
39
+ export const putSettingsDefaultRoute: Handler = (req, params) => putSetting(req, params.namespace, "default");
40
+ export const putSettingRoute: Handler = (req, params) => putSetting(req, params.namespace, params.key);
41
+
42
+ export function getRegisteredSettingEntry(namespace: string, key: string): ConfigEntry | null {
43
+ ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
44
+ const descriptor = getSettingDescriptor(namespace, key);
45
+ return descriptor ? currentSettingEntry(descriptor) : null;
46
+ }
47
+
48
+ export function setRegisteredSettingValue(namespace: string, key: string, value: unknown, updatedBy?: string): ConfigEntry {
49
+ ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
50
+ const descriptor = getSettingDescriptor(namespace, key);
51
+ if (!descriptor) throw new ValidationError("setting not found");
52
+ const merged = mergeSettingDefaults(descriptor.defaults, value);
53
+ const validation = validateSettingValue(namespace, key, merged);
54
+ if (!validation.valid) throw new ValidationError(validation.errors.join("; "));
55
+ return setConfig(namespace, key, merged, updatedBy);
56
+ }
57
+
58
+ async function putSetting(req: Request, namespaceRaw: string | undefined, keyRaw: string | undefined): Promise<Response> {
59
+ const namespace = normalizeConfigPathParam(namespaceRaw, "namespace");
60
+ const key = normalizeConfigPathParam(keyRaw, "key");
61
+ const parsed = await parseBody<unknown>(req);
62
+ if (!parsed.ok) return error(parsed.error, parsed.status);
63
+ if (!isRecord(parsed.body)) return error("JSON object body required", 400);
64
+ if (!Object.prototype.hasOwnProperty.call(parsed.body, "value")) return error("value required", 400);
65
+
66
+ try {
67
+ migrateLocalProviderConfigs();
68
+ ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
69
+ const descriptor = getSettingDescriptor(namespace, key);
70
+ if (!descriptor) return error("setting not found", 404);
71
+ if (descriptor.tier !== "server-runtime") return error("setting is read-only", 403);
72
+ if (!canAccessSetting(req, descriptor.scope.write)) return error("forbidden", 403);
73
+ const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
74
+ const validation = validateSettingValue(namespace, key, parsed.body.value);
75
+ if (!validation.valid) return json({ error: "invalid setting value", details: validation.errors }, 400);
76
+ const entry = setConfig(namespace, key, parsed.body.value, updatedBy);
77
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
78
+ return json(settingEnvelope(req, descriptor, entry), entry.version === 1 ? 201 : 200);
79
+ } catch (e) {
80
+ if (e instanceof ValidationError) return error(e.message, 400);
81
+ throw e;
82
+ }
83
+ }
84
+
85
+ function listVisibleSettings(req: Request): SettingEnvelope[] {
86
+ return listSettingDescriptors()
87
+ .map((descriptor) => settingEnvelope(req, descriptor))
88
+ .filter((setting) => setting.readable);
89
+ }
90
+
91
+ function settingEnvelope(req: Request, descriptor: SettingDescriptor, entry = currentSettingEntry(descriptor)): SettingEnvelope {
92
+ return {
93
+ ...descriptor,
94
+ value: entry.value,
95
+ version: entry.version,
96
+ updatedAt: entry.updatedAt,
97
+ updatedBy: entry.updatedBy,
98
+ readable: canAccessSetting(req, descriptor.scope.read),
99
+ writable: descriptor.tier === "server-runtime" && canAccessSetting(req, descriptor.scope.write),
100
+ };
101
+ }
102
+
103
+ function currentSettingEntry(descriptor: SettingDescriptor): ConfigEntry {
104
+ const entry = getConfig(descriptor.namespace, descriptor.key);
105
+ if (entry) return entry;
106
+ return {
107
+ namespace: descriptor.namespace,
108
+ key: descriptor.key,
109
+ value: descriptor.defaults,
110
+ version: 0,
111
+ updatedAt: "default",
112
+ updatedBy: "system",
113
+ };
114
+ }
115
+
116
+ function canAccessSetting(req: Request, scope: string): boolean {
117
+ return isRequestAuthorizedFor(req, { scope });
118
+ }
119
+
120
+ function mergeSettingDefaults(defaults: unknown, value: unknown): unknown {
121
+ if (!isRecord(defaults) || !isRecord(value)) return value;
122
+ const merged: Record<string, unknown> = { ...defaults };
123
+ for (const [key, item] of Object.entries(value)) {
124
+ merged[key] = mergeSettingDefaults(defaults[key], item);
125
+ }
126
+ return merged;
127
+ }
@@ -3,10 +3,10 @@ import { ValidationError } from "../db";
3
3
  import { cleanString } from "../validation";
4
4
  import { emitConfigChanged } from "../sse";
5
5
  import { error, json, parseBody, type Handler } from "./_shared";
6
- import { getStewardConfigEntry, getWorkspaceConfigEntry, setStewardConfig, setWorkspaceConfig } from "../config-store";
6
+ import { getRegisteredSettingEntry, setRegisteredSettingValue } from "./settings";
7
7
  import { isRecord } from "agent-relay-sdk";
8
8
 
9
- export const getStewardConfigRoute: Handler = () => json(getStewardConfigEntry());
9
+ export const getStewardConfigRoute: Handler = () => json(getRegisteredSettingEntry("steward", "default"));
10
10
 
11
11
  export const putStewardConfigRoute: Handler = async (req) => {
12
12
  const parsed = await parseBody<unknown>(req);
@@ -16,7 +16,7 @@ export const putStewardConfigRoute: Handler = async (req) => {
16
16
  ? parsed.body.value
17
17
  : parsed.body;
18
18
  const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
19
- const entry = setStewardConfig(value, updatedBy);
19
+ const entry = setRegisteredSettingValue("steward", "default", value, updatedBy);
20
20
  emitConfigChanged(entry.namespace, entry.key, entry.version);
21
21
  return json(entry, entry.version === 1 ? 201 : 200);
22
22
  } catch (e) {
@@ -25,7 +25,7 @@ export const putStewardConfigRoute: Handler = async (req) => {
25
25
  }
26
26
  };
27
27
 
28
- export const getWorkspaceConfigRoute: Handler = () => json(getWorkspaceConfigEntry());
28
+ export const getWorkspaceConfigRoute: Handler = () => json(getRegisteredSettingEntry("workspace", "default"));
29
29
 
30
30
  export const putWorkspaceConfigRoute: Handler = async (req) => {
31
31
  const parsed = await parseBody<unknown>(req);
@@ -35,7 +35,7 @@ export const putWorkspaceConfigRoute: Handler = async (req) => {
35
35
  ? parsed.body.value
36
36
  : parsed.body;
37
37
  const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
38
- const entry = setWorkspaceConfig(value, updatedBy);
38
+ const entry = setRegisteredSettingValue("workspace", "default", value, updatedBy);
39
39
  emitConfigChanged(entry.namespace, entry.key, entry.version);
40
40
  return json(entry, entry.version === 1 ? 201 : 200);
41
41
  } catch (e) {
@@ -4,12 +4,43 @@ import { ValidationError, getOrchestrator, upsertIntegrationRegistry } from "../
4
4
  import { auditEvent, authAuditMetadata, authorizeRoute, error, isRootCredentialRequest, json, parseBody, type Handler } from "./_shared";
5
5
  import { cleanString, cleanStringArray, cleanTokenConstraints, optionalEnum } from "../validation";
6
6
  import { createToken, deleteTokenProfile, getToken, getTokenProfile, listTokenProfiles, listTokens, renewToken, revokeToken, updateTokenProfile, upsertTokenProfile } from "../token-db";
7
- import { getComponentAuth } from "../security";
7
+ import { getComponentAuth, getIntegrationAuth, isAuthorized } from "../security";
8
8
  import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, reissueRunnerRuntimeToken } from "../runtime-tokens";
9
9
  import { type SpawnProvider } from "../types";
10
10
 
11
11
  export const getTokens: Handler = () => json(listTokens());
12
12
 
13
+ export const getTokenMe: Handler = (req) => {
14
+ const component = getComponentAuth(req);
15
+ if (component) {
16
+ return json({
17
+ actor: component.sub,
18
+ kind: "component",
19
+ role: component.role,
20
+ scopes: component.scope,
21
+ constraints: component.constraints,
22
+ jti: component.jti,
23
+ exp: component.exp,
24
+ });
25
+ }
26
+
27
+ const integration = getIntegrationAuth(req);
28
+ if (integration) {
29
+ return json({
30
+ actor: integration.name,
31
+ kind: "integration",
32
+ scopes: integration.scopes,
33
+ targets: integration.targets,
34
+ channels: integration.channels,
35
+ });
36
+ }
37
+
38
+ if (isAuthorized(req)) {
39
+ return json({ actor: "server", kind: "server", scopes: ["*"] });
40
+ }
41
+ return error("unauthorized", 401);
42
+ };
43
+
13
44
  function cleanTtlSeconds(value: unknown): number | undefined {
14
45
  if (value === undefined || value === null || value === "") return undefined;
15
46
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
package/src/security.ts CHANGED
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from "node:path";
5
5
  import { getDb } from "./db";
6
6
  import { isPathWithinBase } from "./utils";
7
7
  import type { ComponentToken, TokenConstraints } from "./types";
8
+ import { hasScope, normalizeScope } from "agent-relay-sdk/types";
8
9
 
9
10
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
10
11
 
@@ -99,30 +100,7 @@ type AuthorizationCheck = {
99
100
  resource?: AuthorizationResource;
100
101
  };
101
102
 
102
- const SCOPE_ALIASES: Record<string, string> = {
103
- "admin:*": "system:admin",
104
- "system:write": "system:admin",
105
- "agents:read": "agent:read",
106
- "agents:write": "agent:write",
107
- "messages:read": "message:read",
108
- "messages:write": "message:send",
109
- "tasks:read": "task:read",
110
- "tasks:write": "task:write",
111
- "commands:read": "command:read",
112
- "commands:write": "command:write",
113
- "channels:read": "channel:read",
114
- "channels:write": "channel:write",
115
- "integrations:read": "integration:read",
116
- "integrations:write": "integration:write",
117
- "connectors:read": "integration:read",
118
- "connectors:write": "integration:write",
119
- "events:create": "integration:write",
120
- "tasks:create": "task:write",
121
- };
122
-
123
- export function normalizeScope(scope: string): string {
124
- return SCOPE_ALIASES[scope] ?? scope;
125
- }
103
+ export { normalizeScope };
126
104
 
127
105
  export function getIntegrationAuth(req: Request): IntegrationAuth | null {
128
106
  const token = extractToken(req);
@@ -170,6 +148,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
170
148
  if (pathname === "/api/orchestrators/bootstrap") return "token:write";
171
149
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
172
150
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:attach";
151
+ const settingScope = requiredSettingsScopeFor(method, pathname);
152
+ if (settingScope !== undefined) return settingScope;
173
153
  if (pathname.startsWith("/api/artifacts")) {
174
154
  if (method === "GET" || method === "HEAD") return "artifact:read";
175
155
  if (method === "DELETE") return "artifact:admin";
@@ -201,6 +181,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
201
181
  if (pathname.startsWith("/api/recipes")) return method === "GET" ? "recipes:read" : "recipes:write";
202
182
  if (pathname === "/api/runtime-tokens/renew") return "agent:write";
203
183
  if (pathname.startsWith("/api/runtime-tokens")) return "token:write";
184
+ if (pathname === "/api/tokens/me") return null;
204
185
  if (pathname.startsWith("/api/tokens") || pathname.startsWith("/api/token-profiles")) return method === "GET" ? "token:read" : "token:write";
205
186
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
206
187
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
@@ -251,12 +232,15 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
251
232
  if (pathname === "/api/mcp") return "mcp:use";
252
233
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
253
234
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:attach";
235
+ const settingScope = requiredSettingsScopeFor(method, pathname);
236
+ if (settingScope !== undefined) return settingScope ?? "system:admin";
254
237
  if (pathname.startsWith("/api/commands")) {
255
238
  if (method === "GET") return "command:read";
256
239
  return "command:write";
257
240
  }
258
241
  if (pathname.startsWith("/api/merges")) return method === "GET" ? "command:read" : "command:write";
259
242
  if (pathname.startsWith("/api/workspaces")) return method === "GET" ? "agent:read" : "agent:write";
243
+ if (pathname === "/api/tokens/me") return null;
260
244
  if (pathname.startsWith("/api/tokens") || pathname.startsWith("/api/token-profiles")) return method === "GET" ? "token:read" : "token:write";
261
245
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
262
246
  if (pathname === "/api/recipes/start") return "recipe:start";
@@ -298,6 +282,32 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
298
282
  return "system:admin";
299
283
  }
300
284
 
285
+ const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider"]);
286
+
287
+ function requiredSettingsScopeFor(method: string, pathname: string): string | null | undefined {
288
+ if (pathname === "/api/settings") return method === "GET" ? "settings:read" : null;
289
+ const settingsMatch = pathname.match(/^\/api\/settings\/([^/]+)(?:\/[^/]+)?$/);
290
+ if (settingsMatch) return settingsScope(method, decodeURIComponent(settingsMatch[1]!));
291
+ if (pathname === "/api/steward-config") return settingsScope(method, "steward");
292
+ if (pathname === "/api/workspace-config") return settingsScope(method, "workspace");
293
+ if (pathname === "/api/insights/config") return settingsScope(method, "insights");
294
+ const configMatch = pathname.match(/^\/api\/config\/([^/]+)(?:\/[^/]+)?(?:\/history)?$/);
295
+ if (configMatch) {
296
+ const namespace = decodeURIComponent(configMatch[1]!);
297
+ if (CONFIG_SETTINGS_NAMESPACES.has(namespace)) return settingsScope(method, namespace);
298
+ }
299
+ if (pathname.match(/^\/api\/providers\/[^/]+\/models\/[^/]+$/) && ["PUT", "PATCH", "DELETE"].includes(method)) {
300
+ return "settings:write:provider";
301
+ }
302
+ return undefined;
303
+ }
304
+
305
+ function settingsScope(method: string, namespace: string): string | null {
306
+ if (method === "GET" || method === "HEAD") return `settings:read:${namespace}`;
307
+ if (["PUT", "PATCH", "POST", "DELETE"].includes(method)) return `settings:write:${namespace}`;
308
+ return null;
309
+ }
310
+
301
311
  export function signComponentToken(payload: Omit<ComponentToken, "iat"> & { iat?: number }): string {
302
312
  const header = { alg: "HS256", typ: "JWT" };
303
313
  const body: ComponentToken = {
@@ -404,19 +414,6 @@ function isComponentToken(value: unknown): value is ComponentToken {
404
414
  (record.jti === undefined || typeof record.jti === "string");
405
415
  }
406
416
 
407
- function hasScope(scopes: string[], requiredScope: string): boolean {
408
- const required = normalizeScope(requiredScope);
409
- if (scopes.includes("*")) return true;
410
- for (const rawScope of scopes) {
411
- const scope = normalizeScope(rawScope);
412
- if (scope === "system:admin") return true;
413
- if (scope === required) return true;
414
- const [category] = required.split(":");
415
- if (category && scope === `${category}:*`) return true;
416
- }
417
- return false;
418
- }
419
-
420
417
  /** Authoritative parent lineage from a registering token's signed constraints — agent-initiated
421
418
  * spawn (`spawnedBy`) or the delegating-component path (`parentAgents`). One home, used by both the
422
419
  * HTTP `postAgent` route and the bus register path so `spawned_by` is set however a child registers