agent-relay-server 0.87.0 → 0.88.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 (70) hide show
  1. package/docs/openapi.json +97 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DS4Hbn0k.js → MessageBubble-bI9fFi55.js} +3 -3
  4. package/public/assets/{MessageBubble-DS4Hbn0k.js.map → MessageBubble-bI9fFi55.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BYlragEi.js → PlanGraphPanel-a3Z57nIZ.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BYlragEi.js.map → PlanGraphPanel-a3Z57nIZ.js.map} +1 -1
  7. package/public/assets/{activity-CcGFS_sY.js → activity-VTlBVIox.js} +2 -2
  8. package/public/assets/{activity-CcGFS_sY.js.map → activity-VTlBVIox.js.map} +1 -1
  9. package/public/assets/{agents-BTh8SQiP.js → agents-C2TNRCn1.js} +2 -2
  10. package/public/assets/{agents-BTh8SQiP.js.map → agents-C2TNRCn1.js.map} +1 -1
  11. package/public/assets/{automation-izYGpZyQ.js → automation-CUSyRZla.js} +2 -2
  12. package/public/assets/{automation-izYGpZyQ.js.map → automation-CUSyRZla.js.map} +1 -1
  13. package/public/assets/{chat-ThyPRUPq.js → chat-BEssCn_H.js} +2 -2
  14. package/public/assets/{chat-ThyPRUPq.js.map → chat-BEssCn_H.js.map} +1 -1
  15. package/public/assets/{formatted-body-impl-DEYxGoiw.js → formatted-body-impl-DBbmythT.js} +2 -2
  16. package/public/assets/{formatted-body-impl-DEYxGoiw.js.map → formatted-body-impl-DBbmythT.js.map} +1 -1
  17. package/public/assets/{index-Bd1NBLyd.js → index-CiBdEE9x.js} +6 -6
  18. package/public/assets/{index-Bd1NBLyd.js.map → index-CiBdEE9x.js.map} +1 -1
  19. package/public/assets/index-F-FSPv4X.css +2 -0
  20. package/public/assets/{insights-DAVKQ_SF.js → insights-9P_MGbMS.js} +2 -2
  21. package/public/assets/{insights-DAVKQ_SF.js.map → insights-9P_MGbMS.js.map} +1 -1
  22. package/public/assets/{maintenance-BL4R9076.js → maintenance-CA1XujU3.js} +2 -2
  23. package/public/assets/{maintenance-BL4R9076.js.map → maintenance-CA1XujU3.js.map} +1 -1
  24. package/public/assets/managed-agents-DCfSlqW7.js +4 -0
  25. package/public/assets/managed-agents-DCfSlqW7.js.map +1 -0
  26. package/public/assets/{markdown-preview-impl-DK0wNWXJ.js → markdown-preview-impl-MgZ3Nr7K.js} +2 -2
  27. package/public/assets/{markdown-preview-impl-DK0wNWXJ.js.map → markdown-preview-impl-MgZ3Nr7K.js.map} +1 -1
  28. package/public/assets/{memory-BJEkDkwl.js → memory-DatJLjrc.js} +2 -2
  29. package/public/assets/{memory-BJEkDkwl.js.map → memory-DatJLjrc.js.map} +1 -1
  30. package/public/assets/{messages-DBn9tXx2.js → messages-CQsmbtzx.js} +2 -2
  31. package/public/assets/{messages-DBn9tXx2.js.map → messages-CQsmbtzx.js.map} +1 -1
  32. package/public/assets/{orchestrators-Bc1eC16Q.js → orchestrators-CAWYWXmh.js} +2 -2
  33. package/public/assets/{orchestrators-Bc1eC16Q.js.map → orchestrators-CAWYWXmh.js.map} +1 -1
  34. package/public/assets/overview-EqnqXVUb.js +2 -0
  35. package/public/assets/{overview-Bw7zrSqR.js.map → overview-EqnqXVUb.js.map} +1 -1
  36. package/public/assets/{pairs-BSwWnLK2.js → pairs-BfKenirO.js} +2 -2
  37. package/public/assets/{pairs-BSwWnLK2.js.map → pairs-BfKenirO.js.map} +1 -1
  38. package/public/assets/{security-wTBapq4K.js → security-CJVJ9MgS.js} +2 -2
  39. package/public/assets/{security-wTBapq4K.js.map → security-CJVJ9MgS.js.map} +1 -1
  40. package/public/assets/{select-D6HFOeLW.js → select-BcmIzk-E.js} +2 -2
  41. package/public/assets/{select-D6HFOeLW.js.map → select-BcmIzk-E.js.map} +1 -1
  42. package/public/assets/{settings-CDzp7vqJ.js → settings-BWO-UyHv.js} +2 -2
  43. package/public/assets/{settings-CDzp7vqJ.js.map → settings-BWO-UyHv.js.map} +1 -1
  44. package/public/assets/{tasks-Bd7Wjy6e.js → tasks-DTdI_MJi.js} +2 -2
  45. package/public/assets/{tasks-Bd7Wjy6e.js.map → tasks-DTdI_MJi.js.map} +1 -1
  46. package/public/assets/{teams-l3GUr2XV.js → teams-C9C3KVaV.js} +2 -2
  47. package/public/assets/{teams-l3GUr2XV.js.map → teams-C9C3KVaV.js.map} +1 -1
  48. package/public/assets/{terminal-viewer-impl-BQMMWctd.js → terminal-viewer-impl-BDBa94-B.js} +2 -2
  49. package/public/assets/{terminal-viewer-impl-BQMMWctd.js.map → terminal-viewer-impl-BDBa94-B.js.map} +1 -1
  50. package/public/assets/{user-avatar-DcA69UJK.js → user-avatar-BL-CJwnn.js} +2 -2
  51. package/public/assets/{user-avatar-DcA69UJK.js.map → user-avatar-BL-CJwnn.js.map} +1 -1
  52. package/public/assets/{work-queue-BYlFeJ1g.js → work-queue-Vc_2-4DG.js} +2 -2
  53. package/public/assets/{work-queue-BYlFeJ1g.js.map → work-queue-Vc_2-4DG.js.map} +1 -1
  54. package/public/index.html +2 -2
  55. package/src/commands-db.ts +19 -2
  56. package/src/db/activity.ts +92 -0
  57. package/src/db/provider-quotas.ts +65 -4
  58. package/src/mcp/index.ts +3 -0
  59. package/src/mcp-ntfy-tools.ts +35 -0
  60. package/src/ntfy.ts +222 -0
  61. package/src/quota-advisory.ts +3 -2
  62. package/src/quota-threshold-config.ts +1 -1
  63. package/src/routes/index.ts +5 -2
  64. package/src/routes/provider-quotas.ts +50 -0
  65. package/src/routes/spawn-policy.ts +19 -2
  66. package/src/security.ts +3 -0
  67. package/public/assets/index-BjbvhfFt.css +0 -2
  68. package/public/assets/managed-agents-C7n1gl7E.js +0 -2
  69. package/public/assets/managed-agents-C7n1gl7E.js.map +0 -1
  70. package/public/assets/overview-Bw7zrSqR.js +0 -2
package/src/ntfy.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { MAX_BODY_BYTES } from "./config";
3
+ import { getConfig } from "./config-store";
4
+ import { upsertAgent, ValidationError } from "./db";
5
+ import { registerChannelTypeSetting } from "./settings/modules";
6
+ import { cleanString, cleanStringArray } from "./validation";
7
+ import { errMessage, isRecord, type JSONSchema } from "agent-relay-sdk";
8
+
9
+ export const NTFY_CONFIG_NAMESPACE = "channel-type";
10
+ export const NTFY_CONFIG_KEY = "ntfy";
11
+ export const NTFY_CHANNEL_ID = "ntfy:default";
12
+
13
+ export const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"] as const;
14
+ export type NtfyPriority = (typeof NTFY_PRIORITIES)[number];
15
+
16
+ export interface NtfyChannelConfig {
17
+ enabled: boolean;
18
+ serverUrl: string;
19
+ topic: string;
20
+ tokenEnv?: string;
21
+ username?: string;
22
+ passwordEnv?: string;
23
+ defaultTags: string[];
24
+ defaultClick?: string;
25
+ timeoutMs: number;
26
+ }
27
+
28
+ export interface NtfyNotificationInput {
29
+ title: string;
30
+ body: string;
31
+ priority?: NtfyPriority;
32
+ tags?: string[];
33
+ click?: string;
34
+ }
35
+
36
+ export interface NtfyNotificationResult {
37
+ ok: true;
38
+ status: number;
39
+ channelId: string;
40
+ topic: string;
41
+ priority: NtfyPriority;
42
+ tags: string[];
43
+ }
44
+
45
+ const DEFAULT_NTFY_CONFIG: NtfyChannelConfig = {
46
+ enabled: false,
47
+ serverUrl: "",
48
+ topic: "",
49
+ defaultTags: [],
50
+ timeoutMs: 15_000,
51
+ };
52
+
53
+ const NTFY_CONFIG_SCHEMA: JSONSchema = {
54
+ type: "object",
55
+ additionalProperties: false,
56
+ required: ["enabled", "serverUrl", "topic"],
57
+ properties: {
58
+ enabled: { type: "boolean" },
59
+ serverUrl: { type: "string", maxLength: 500 },
60
+ topic: { type: "string", maxLength: 200 },
61
+ tokenEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
62
+ username: { type: "string", maxLength: 120 },
63
+ passwordEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
64
+ defaultTags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 40 } },
65
+ defaultClick: { type: "string", maxLength: 1000 },
66
+ timeoutMs: { type: "integer", minimum: 1_000, maximum: 60_000 },
67
+ },
68
+ };
69
+
70
+ registerChannelTypeSetting({
71
+ type: NTFY_CONFIG_KEY,
72
+ schema: NTFY_CONFIG_SCHEMA,
73
+ defaults: DEFAULT_NTFY_CONFIG,
74
+ display: {
75
+ label: "ntfy outgoing channel",
76
+ description: "Configures the built-in egress-only ntfy notification channel.",
77
+ order: 121,
78
+ },
79
+ });
80
+
81
+ export function getNtfyConfig(): NtfyChannelConfig {
82
+ const stored = getConfig<Partial<NtfyChannelConfig>>(NTFY_CONFIG_NAMESPACE, NTFY_CONFIG_KEY)?.value;
83
+ return normalizeNtfyConfig({ ...DEFAULT_NTFY_CONFIG, ...(isRecord(stored) ? stored : {}) });
84
+ }
85
+
86
+ export async function sendNtfyNotification(input: NtfyNotificationInput): Promise<NtfyNotificationResult> {
87
+ const config = getNtfyConfig();
88
+ validateSendConfig(config);
89
+ const title = cleanString(input.title, "title", { required: true, max: 200 })!;
90
+ const body = cleanString(input.body, "body", { required: true, max: MAX_BODY_BYTES })!;
91
+ const priority = input.priority ?? "default";
92
+ const tags = [...new Set([...(config.defaultTags ?? []), ...(input.tags ?? [])].map((tag) => tag.trim()).filter(Boolean))];
93
+ const click = cleanString(input.click ?? config.defaultClick, "click", { max: 1000 });
94
+ const url = ntfyTopicUrl(config);
95
+ const headers = ntfyHeaders({ config, title, priority, tags, click });
96
+
97
+ ensureNtfyChannelRegistered(config);
98
+
99
+ const controller = new AbortController();
100
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
101
+ let response: Response;
102
+ try {
103
+ response = await fetch(url, {
104
+ method: "POST",
105
+ headers,
106
+ body,
107
+ signal: controller.signal,
108
+ });
109
+ } catch (error) {
110
+ if (controller.signal.aborted) throw new Error(`ntfy notify timed out after ${config.timeoutMs}ms`);
111
+ throw new Error(`ntfy notify failed: ${errMessage(error)}`);
112
+ } finally {
113
+ clearTimeout(timeout);
114
+ }
115
+
116
+ if (!response.ok) {
117
+ const text = await response.text().catch(() => "");
118
+ throw new Error(`ntfy notify failed: HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
119
+ }
120
+
121
+ return { ok: true, status: response.status, channelId: NTFY_CHANNEL_ID, topic: config.topic, priority, tags };
122
+ }
123
+
124
+ function normalizeNtfyConfig(value: unknown): NtfyChannelConfig {
125
+ if (!isRecord(value)) throw new ValidationError("ntfy channel config must be an object");
126
+ const timeoutMs = value.timeoutMs === undefined ? DEFAULT_NTFY_CONFIG.timeoutMs : value.timeoutMs;
127
+ if (typeof value.enabled !== "boolean") throw new ValidationError("ntfy.enabled must be a boolean");
128
+ if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 60_000) {
129
+ throw new ValidationError("ntfy.timeoutMs must be an integer between 1000 and 60000");
130
+ }
131
+ return {
132
+ enabled: value.enabled,
133
+ serverUrl: cleanString(value.serverUrl, "ntfy.serverUrl", { max: 500 }) ?? "",
134
+ topic: cleanString(value.topic, "ntfy.topic", { max: 200 }) ?? "",
135
+ tokenEnv: cleanString(value.tokenEnv, "ntfy.tokenEnv", { max: 120 }),
136
+ username: cleanString(value.username, "ntfy.username", { max: 120 }),
137
+ passwordEnv: cleanString(value.passwordEnv, "ntfy.passwordEnv", { max: 120 }),
138
+ defaultTags: cleanStringArray(value.defaultTags, "ntfy.defaultTags", { itemMax: 40, maxItems: 20 }) ?? [],
139
+ defaultClick: cleanString(value.defaultClick, "ntfy.defaultClick", { max: 1000 }),
140
+ timeoutMs,
141
+ };
142
+ }
143
+
144
+ function validateSendConfig(config: NtfyChannelConfig): void {
145
+ if (!config.enabled) throw new ValidationError("ntfy channel is disabled");
146
+ if (!config.serverUrl || !config.topic) {
147
+ throw new ValidationError("ntfy channel requires channel-type/ntfy serverUrl and topic");
148
+ }
149
+ if (config.topic.includes("/") || config.topic.includes("?") || config.topic.includes("#")) {
150
+ throw new ValidationError("ntfy.topic must be a single topic name, not a path or URL");
151
+ }
152
+ if (config.tokenEnv && (config.username || config.passwordEnv)) {
153
+ throw new ValidationError("ntfy auth must use tokenEnv or username/passwordEnv, not both");
154
+ }
155
+ if (config.passwordEnv && !config.username) throw new ValidationError("ntfy.username required when passwordEnv is set");
156
+ }
157
+
158
+ function ntfyTopicUrl(config: NtfyChannelConfig): string {
159
+ let base: URL;
160
+ try {
161
+ base = new URL(config.serverUrl.endsWith("/") ? config.serverUrl : `${config.serverUrl}/`);
162
+ } catch {
163
+ throw new ValidationError("ntfy.serverUrl must be a valid URL");
164
+ }
165
+ return new URL(encodeURIComponent(config.topic), base).toString();
166
+ }
167
+
168
+ function ntfyHeaders(input: {
169
+ config: NtfyChannelConfig;
170
+ title: string;
171
+ priority: NtfyPriority;
172
+ tags: string[];
173
+ click?: string;
174
+ }): Record<string, string> {
175
+ const headers: Record<string, string> = {
176
+ Title: input.title,
177
+ Priority: String(priorityValue(input.priority)),
178
+ };
179
+ if (input.tags.length) headers.Tags = input.tags.join(",");
180
+ if (input.click) headers.Click = input.click;
181
+ const authorization = authHeader(input.config);
182
+ if (authorization) headers.Authorization = authorization;
183
+ return headers;
184
+ }
185
+
186
+ function authHeader(config: NtfyChannelConfig): string | undefined {
187
+ if (config.tokenEnv) {
188
+ const token = cleanString(process.env[config.tokenEnv], config.tokenEnv, { required: true, max: 4_000 })!;
189
+ return `Bearer ${token}`;
190
+ }
191
+ if (config.username && config.passwordEnv) {
192
+ const password = cleanString(process.env[config.passwordEnv], config.passwordEnv, { required: true, max: 4_000 })!;
193
+ return `Basic ${Buffer.from(`${config.username}:${password}`).toString("base64")}`;
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ function priorityValue(priority: NtfyPriority): number {
199
+ return NTFY_PRIORITIES.indexOf(priority) + 1;
200
+ }
201
+
202
+ function ensureNtfyChannelRegistered(config: NtfyChannelConfig): void {
203
+ upsertAgent({
204
+ id: NTFY_CHANNEL_ID,
205
+ name: "ntfy",
206
+ kind: "channel",
207
+ tags: ["channel", "ntfy", "channel:ntfy"],
208
+ capabilities: ["channel", "ntfy", "notify"],
209
+ status: "online",
210
+ ready: true,
211
+ meta: {
212
+ kind: "channel",
213
+ provider: "ntfy",
214
+ accountId: "default",
215
+ channelType: "ntfy",
216
+ transport: "ntfy",
217
+ direction: "outbound",
218
+ displayName: "ntfy",
219
+ topicChannels: [config.topic],
220
+ },
221
+ });
222
+ }
@@ -29,6 +29,7 @@ type AgentRecipientRow = {
29
29
  };
30
30
 
31
31
  const RESET_ROUNDING_PERCENT = 99;
32
+ const QUOTA_THRESHOLD_EPSILON = 1e-9;
32
33
 
33
34
  export function installQuotaAdvisoryHandler(): void {
34
35
  setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
@@ -62,14 +63,14 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
62
63
  continue;
63
64
  }
64
65
 
65
- if (remaining > config.warnRemaining) {
66
+ if (remaining - config.warnRemaining > QUOTA_THRESHOLD_EPSILON) {
66
67
  if (previous) delete nextState[window.name];
67
68
  continue;
68
69
  }
69
70
 
70
71
  const band = quotaRemainingBand(remaining, config.bandSize);
71
72
  if (previous?.lastQuotaAdvisoryBand === band) continue;
72
- const stage: QuotaAdvisoryStage = remaining <= config.criticalRemaining ? "critical" : "warn";
73
+ const stage: QuotaAdvisoryStage = remaining - config.criticalRemaining <= QUOTA_THRESHOLD_EPSILON ? "critical" : "warn";
73
74
  notifyQuotaAdvisory({
74
75
  provider: input.provider,
75
76
  accountKey: input.accountKey,
@@ -4,7 +4,7 @@ import type { NotificationsConfig } from "./types";
4
4
 
5
5
  export const DEFAULT_QUOTA_THRESHOLD_CONFIG: NotificationsConfig["quotaThreshold"] = {
6
6
  enabled: true,
7
- warnRemaining: 0.25,
7
+ warnRemaining: 0.15,
8
8
  criticalRemaining: 0.1,
9
9
  resetRemaining: 0.99,
10
10
  bandSize: 0.05,
@@ -6,7 +6,7 @@ import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, h
6
6
  import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
7
7
  import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
8
8
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
9
- import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
9
+ import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaPublisherLeaseAcquireRoute, postProviderQuotaPublisherLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
10
10
  import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
11
11
  import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
12
12
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
@@ -14,7 +14,7 @@ import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute,
14
14
  import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
15
15
  import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRoute, getMessageStatusRoute, getMessageThread, getMessages, getQueuedMessagesRoute, patchMessage, postClaimMessage, postMessage, postMessageDeliveryAction, postMessageDeliveryAttempt, postMessageReaction, postRenewMessageClaim, postSystemBroadcast } from "./messages";
16
16
  import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
17
- import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
17
+ import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyDiagnostics, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
18
18
  import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
19
19
  import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
20
20
  import { error, type Handler } from "./_shared";
@@ -112,6 +112,8 @@ const routes: Route[] = [
112
112
  route("POST", "/api/route", postRouteAdvice),
113
113
  route("GET", "/api/provider-quotas", getProviderQuotasRoute),
114
114
  route("POST", "/api/provider-quotas", postProviderQuotaRoute),
115
+ route("POST", "/api/provider-quota-leases/acquire", postProviderQuotaPublisherLeaseAcquireRoute),
116
+ route("POST", "/api/provider-quota-leases/release", postProviderQuotaPublisherLeaseReleaseRoute),
115
117
  route("POST", "/api/orchestrators/:id/provider-quota-leases/acquire", postProviderQuotaLeaseAcquireRoute),
116
118
  route("POST", "/api/orchestrators/:id/provider-quota-leases/release", postProviderQuotaLeaseReleaseRoute),
117
119
  route("GET", "/api/agents/spawn/directories", getHostDirectories),
@@ -259,6 +261,7 @@ const routes: Route[] = [
259
261
  route("GET", "/api/spawn-policies/health", getSpawnPoliciesHealth),
260
262
  route("GET", "/api/spawn-policy/:name/status", getSpawnPolicyStatus),
261
263
  route("GET", "/api/spawn-policy/:name/health", getSpawnPolicyHealth),
264
+ route("GET", "/api/spawn-policy/:name/diagnostics", getSpawnPolicyDiagnostics),
262
265
  route("GET", "/api/providers", getProvidersRoute),
263
266
  route("GET", "/api/providers/config", getProviderConfigsRoute),
264
267
  route("GET", "/api/providers/quota-config", getProviderQuotaConfigsRoute),
@@ -66,6 +66,56 @@ function cleanLeaseBody(body: unknown): {
66
66
  return { provider, accountKey, ...(leaseToken ? { leaseToken } : {}), ttlMs };
67
67
  }
68
68
 
69
+ function cleanPublisherLeaseBody(body: unknown): {
70
+ provider: string;
71
+ accountKey: string;
72
+ sourceAgentId: string;
73
+ leaseToken?: string;
74
+ ttlMs: number;
75
+ } | string {
76
+ const input = cleanLeaseBody(body);
77
+ if (typeof input === "string") return input;
78
+ if (!isRecord(body)) return "JSON object body required";
79
+ const sourceAgentId = cleanString(body.sourceAgentId, "sourceAgentId", { required: true, max: 200 })!;
80
+ return { ...input, sourceAgentId };
81
+ }
82
+
83
+ const runnerLeaseHolderId = (sourceAgentId: string) => `runner:${sourceAgentId}`;
84
+
85
+ export const postProviderQuotaPublisherLeaseAcquireRoute: Handler = async (req) => {
86
+ const parsed = await parseBody<unknown>(req);
87
+ if (!parsed.ok) return error(parsed.error, parsed.status);
88
+ const input = cleanPublisherLeaseBody(parsed.body);
89
+ if (typeof input === "string") return error(input);
90
+ const denied = authorizeRoute(req, { scope: "quota:write", resource: { agentId: input.sourceAgentId } });
91
+ if (denied) return denied;
92
+ return json(acquireProviderQuotaLease({
93
+ provider: input.provider,
94
+ accountKey: input.accountKey,
95
+ orchestratorId: runnerLeaseHolderId(input.sourceAgentId),
96
+ leaseToken: input.leaseToken,
97
+ ttlMs: input.ttlMs,
98
+ }));
99
+ };
100
+
101
+ export const postProviderQuotaPublisherLeaseReleaseRoute: Handler = async (req) => {
102
+ const parsed = await parseBody<unknown>(req);
103
+ if (!parsed.ok) return error(parsed.error, parsed.status);
104
+ const input = cleanPublisherLeaseBody(parsed.body);
105
+ if (typeof input === "string") return error(input);
106
+ if (!input.leaseToken) return error("leaseToken required");
107
+ const denied = authorizeRoute(req, { scope: "quota:write", resource: { agentId: input.sourceAgentId } });
108
+ if (denied) return denied;
109
+ return json({
110
+ released: releaseProviderQuotaLease({
111
+ provider: input.provider,
112
+ accountKey: input.accountKey,
113
+ orchestratorId: runnerLeaseHolderId(input.sourceAgentId),
114
+ leaseToken: input.leaseToken,
115
+ }),
116
+ });
117
+ };
118
+
69
119
  export const postProviderQuotaLeaseAcquireRoute: Handler = async (req, params) => {
70
120
  const orch = getOrchestrator(params.id!);
71
121
  if (!orch) return error("orchestrator not found", 404);
@@ -1,6 +1,6 @@
1
1
  // Auto-split from routes.ts (#299). Domain: spawn-policy.
2
- import { ValidationError, getOrchestrator } from "../db";
3
- import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, spawnRequestId, type Handler } from "./_shared";
2
+ import { ValidationError, getCrashReport, getManagedPolicyTimeline, getOrchestrator } from "../db";
3
+ import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, parseQueryInt, spawnRequestId, type Handler } from "./_shared";
4
4
  import { buildManagedSpawnParams } from "../managed-policy";
5
5
  import { cleanString } from "../validation";
6
6
  import { createCommand } from "../commands-db";
@@ -229,3 +229,20 @@ export const getSpawnPolicyHealth: Handler = (_req, params) => {
229
229
  const entry = getSpawnPolicy(params.name!);
230
230
  return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
231
231
  };
232
+
233
+ export const getSpawnPolicyDiagnostics: Handler = (req, params) => {
234
+ const entry = getSpawnPolicy(params.name!);
235
+ if (!entry) return error("spawn policy not found", 404);
236
+ const url = new URL(req.url);
237
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 200, clamp: true });
238
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 200");
239
+ const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
240
+ if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
241
+ const status = policyStatusPayload(entry.value);
242
+ const agentId = typeof status.state.agentId === "string" ? status.state.agentId : undefined;
243
+ return json({
244
+ ...status,
245
+ timeline: getManagedPolicyTimeline(entry.value.name, { limit: limitRaw ?? 100, since: sinceRaw ?? undefined }),
246
+ crashReport: agentId ? getCrashReport(agentId) : null,
247
+ });
248
+ };
package/src/security.ts CHANGED
@@ -195,6 +195,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
195
195
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
196
196
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
197
197
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
198
+ if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
199
+ if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
198
200
  if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
199
201
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
200
202
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
@@ -277,6 +279,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
277
279
  // Spawn requires the spawn capability (see requiredScopeFor above) — matches the in-service gate.
278
280
  if (pathname === "/api/agents/spawn") return "command:spawn";
279
281
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
282
+ if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
280
283
  if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
281
284
  // Per-provider quota config (#605): reads are agent:read (the orchestrator
282
285
  // collector reads its config each tick); writes are settings:write:provider.