agent-relay-server 0.88.0 → 0.88.2
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.
- package/docs/openapi.json +59 -1
- package/package.json +2 -2
- package/src/db/provider-quotas.ts +127 -8
- package/src/routes/index.ts +3 -1
- package/src/routes/provider-quotas.ts +50 -0
- package/src/security.ts +3 -0
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.88.
|
|
5
|
+
"version": "0.88.2",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
|
@@ -1161,6 +1161,64 @@
|
|
|
1161
1161
|
]
|
|
1162
1162
|
}
|
|
1163
1163
|
},
|
|
1164
|
+
"/api/provider-quota-leases/acquire": {
|
|
1165
|
+
"post": {
|
|
1166
|
+
"operationId": "postProviderQuotaPublisherLeaseAcquireRoute",
|
|
1167
|
+
"summary": "Acquire provider quota publisher lease",
|
|
1168
|
+
"tags": [
|
|
1169
|
+
"Agents"
|
|
1170
|
+
],
|
|
1171
|
+
"description": "Runner-scoped election endpoint. Grants or renews the single publisher lease for one `(provider, accountKey)` when no non-expired holder exists, or returns the current holder and retry delay. The provider token must be allowed to act as the supplied `sourceAgentId`.",
|
|
1172
|
+
"responses": {
|
|
1173
|
+
"200": {
|
|
1174
|
+
"description": "Success",
|
|
1175
|
+
"content": {
|
|
1176
|
+
"application/json": {}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
"security": [
|
|
1181
|
+
{
|
|
1182
|
+
"bearerAuth": []
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
"tokenHeader": []
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
"tokenQuery": []
|
|
1189
|
+
}
|
|
1190
|
+
]
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
"/api/provider-quota-leases/release": {
|
|
1194
|
+
"post": {
|
|
1195
|
+
"operationId": "postProviderQuotaPublisherLeaseReleaseRoute",
|
|
1196
|
+
"summary": "Release provider quota publisher lease",
|
|
1197
|
+
"tags": [
|
|
1198
|
+
"Agents"
|
|
1199
|
+
],
|
|
1200
|
+
"description": "Releases a provider quota publisher lease only when `provider`, `accountKey`, `sourceAgentId`, and `leaseToken` all match the current runner holder.",
|
|
1201
|
+
"responses": {
|
|
1202
|
+
"200": {
|
|
1203
|
+
"description": "Success",
|
|
1204
|
+
"content": {
|
|
1205
|
+
"application/json": {}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
},
|
|
1209
|
+
"security": [
|
|
1210
|
+
{
|
|
1211
|
+
"bearerAuth": []
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
"tokenHeader": []
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
"tokenQuery": []
|
|
1218
|
+
}
|
|
1219
|
+
]
|
|
1220
|
+
}
|
|
1221
|
+
},
|
|
1164
1222
|
"/api/orchestrators/{id}/provider-quota-leases/acquire": {
|
|
1165
1223
|
"post": {
|
|
1166
1224
|
"operationId": "postProviderQuotaLeaseAcquireRoute",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.88.
|
|
3
|
+
"version": "0.88.2",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"CONTRIBUTING.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"agent-relay-sdk": "0.2.
|
|
36
|
+
"agent-relay-sdk": "0.2.67",
|
|
37
37
|
"ajv": "^8.20.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
@@ -45,6 +45,10 @@ type ProviderQuotaSnapshotRow = {
|
|
|
45
45
|
last_error?: string | null;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
type StoredQuotaState = QuotaState & {
|
|
49
|
+
_windowUpdatedAt?: Record<string, number>;
|
|
50
|
+
};
|
|
51
|
+
|
|
48
52
|
function cleanKey(value: string): string {
|
|
49
53
|
return value.trim();
|
|
50
54
|
}
|
|
@@ -185,6 +189,118 @@ function quotaFingerprint(quota: QuotaState): string {
|
|
|
185
189
|
})).sort((a, b) => a.name.localeCompare(b.name) || a.unit.localeCompare(b.unit)));
|
|
186
190
|
}
|
|
187
191
|
|
|
192
|
+
function quotaWindowKey(window: QuotaState["windows"][number]): string {
|
|
193
|
+
return `${window.name}:${window.unit}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function publicQuotaState(quota: StoredQuotaState): QuotaState {
|
|
197
|
+
return {
|
|
198
|
+
windows: quota.windows.map((window) => ({ ...window })),
|
|
199
|
+
source: quota.source,
|
|
200
|
+
updatedAt: quota.updatedAt,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseStoredQuotaState(value: string, fallback: QuotaState): StoredQuotaState {
|
|
205
|
+
const quota = parseJson<StoredQuotaState>(value, fallback);
|
|
206
|
+
return {
|
|
207
|
+
...publicQuotaState(quota),
|
|
208
|
+
...(quota._windowUpdatedAt && typeof quota._windowUpdatedAt === "object" ? { _windowUpdatedAt: quota._windowUpdatedAt } : {}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function storedQuotaWindowSampleTimes(quota: StoredQuotaState): Map<string, number> {
|
|
213
|
+
const times = new Map<string, number>();
|
|
214
|
+
const rawTimes = quota._windowUpdatedAt && typeof quota._windowUpdatedAt === "object" ? quota._windowUpdatedAt : {};
|
|
215
|
+
for (const window of quota.windows) {
|
|
216
|
+
const key = quotaWindowKey(window);
|
|
217
|
+
const value = rawTimes[key];
|
|
218
|
+
if (typeof value === "number" && Number.isFinite(value)) times.set(key, value);
|
|
219
|
+
}
|
|
220
|
+
return times;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function quotaWithWindowSampleTimes(quota: QuotaState, times: Map<string, number>): StoredQuotaState {
|
|
224
|
+
const windowUpdatedAt: Record<string, number> = {};
|
|
225
|
+
for (const window of quota.windows) {
|
|
226
|
+
const timestamp = times.get(quotaWindowKey(window));
|
|
227
|
+
if (timestamp !== undefined) windowUpdatedAt[quotaWindowKey(window)] = timestamp;
|
|
228
|
+
}
|
|
229
|
+
return Object.keys(windowUpdatedAt).length > 0
|
|
230
|
+
? { ...quota, _windowUpdatedAt: windowUpdatedAt }
|
|
231
|
+
: quota;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function quotaWindowSampleTimes(provider: string, accountKey: string, fallback?: StoredQuotaState): Map<string, number> {
|
|
235
|
+
const times = new Map<string, number>();
|
|
236
|
+
const storedTimes = fallback ? storedQuotaWindowSampleTimes(fallback) : new Map<string, number>();
|
|
237
|
+
for (const [key, timestamp] of storedTimes) times.set(key, timestamp);
|
|
238
|
+
const rows = getDb().query(`
|
|
239
|
+
SELECT quota_state FROM quota_snapshots
|
|
240
|
+
WHERE provider = ? AND account_key = ? AND unavailable = 0
|
|
241
|
+
ORDER BY captured_at DESC, id DESC
|
|
242
|
+
`).all(provider, accountKey) as Array<{ quota_state: string }>;
|
|
243
|
+
for (const row of rows) {
|
|
244
|
+
const quota = parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: 0 });
|
|
245
|
+
for (const window of quota.windows) {
|
|
246
|
+
const key = quotaWindowKey(window);
|
|
247
|
+
if (storedTimes.has(key)) continue;
|
|
248
|
+
times.set(key, Math.max(times.get(key) ?? 0, quota.updatedAt));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (fallback) {
|
|
252
|
+
for (const window of fallback.windows) {
|
|
253
|
+
const key = quotaWindowKey(window);
|
|
254
|
+
if (!times.has(key)) times.set(key, fallback.updatedAt);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return times;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: QuotaState, provider: string, accountKey: string): {
|
|
261
|
+
quota: StoredQuotaState;
|
|
262
|
+
acceptedIncomingWindow: boolean;
|
|
263
|
+
} {
|
|
264
|
+
if (!existing) {
|
|
265
|
+
const times = new Map(incoming.windows.map((window) => [quotaWindowKey(window), incoming.updatedAt]));
|
|
266
|
+
return { quota: quotaWithWindowSampleTimes(incoming, times), acceptedIncomingWindow: true };
|
|
267
|
+
}
|
|
268
|
+
const existingTimes = quotaWindowSampleTimes(provider, accountKey, existing);
|
|
269
|
+
const incomingByKey = new Map(incoming.windows.map((window) => [quotaWindowKey(window), window]));
|
|
270
|
+
const mergedTimes = new Map<string, number>();
|
|
271
|
+
let acceptedIncomingWindow = false;
|
|
272
|
+
let updatedAt = existing.updatedAt;
|
|
273
|
+
let source = existing.source;
|
|
274
|
+
const windows = existing.windows.map((window) => {
|
|
275
|
+
const key = quotaWindowKey(window);
|
|
276
|
+
const incomingWindow = incomingByKey.get(key);
|
|
277
|
+
const existingUpdatedAt = existingTimes.get(key) ?? existing.updatedAt;
|
|
278
|
+
if (!incomingWindow) {
|
|
279
|
+
mergedTimes.set(key, existingUpdatedAt);
|
|
280
|
+
return window;
|
|
281
|
+
}
|
|
282
|
+
if (incoming.updatedAt < existingUpdatedAt) {
|
|
283
|
+
mergedTimes.set(key, existingUpdatedAt);
|
|
284
|
+
return window;
|
|
285
|
+
}
|
|
286
|
+
acceptedIncomingWindow = true;
|
|
287
|
+
updatedAt = Math.max(updatedAt, incoming.updatedAt);
|
|
288
|
+
source = incoming.source;
|
|
289
|
+
mergedTimes.set(key, incoming.updatedAt);
|
|
290
|
+
return incomingWindow;
|
|
291
|
+
});
|
|
292
|
+
const existingKeys = new Set(existing.windows.map(quotaWindowKey));
|
|
293
|
+
for (const window of incoming.windows) {
|
|
294
|
+
if (existingKeys.has(quotaWindowKey(window))) continue;
|
|
295
|
+
acceptedIncomingWindow = true;
|
|
296
|
+
windows.push(window);
|
|
297
|
+
updatedAt = Math.max(updatedAt, incoming.updatedAt);
|
|
298
|
+
source = incoming.source;
|
|
299
|
+
mergedTimes.set(quotaWindowKey(window), incoming.updatedAt);
|
|
300
|
+
}
|
|
301
|
+
return { quota: quotaWithWindowSampleTimes({ windows, source, updatedAt }, mergedTimes), acceptedIncomingWindow };
|
|
302
|
+
}
|
|
303
|
+
|
|
188
304
|
function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
|
|
189
305
|
const base = {
|
|
190
306
|
provider: row.provider,
|
|
@@ -199,14 +315,14 @@ function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
|
|
|
199
315
|
}
|
|
200
316
|
return {
|
|
201
317
|
...base,
|
|
202
|
-
quota:
|
|
318
|
+
quota: publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: row.captured_at })),
|
|
203
319
|
};
|
|
204
320
|
}
|
|
205
321
|
|
|
206
322
|
function rowToRecord(row: ProviderQuotaRow, history: ProviderQuotaSnapshot[], now: number): ProviderQuotaRecord {
|
|
207
323
|
const lastError = parseJson<ProviderQuotaError | undefined>(row.last_error, undefined);
|
|
208
324
|
const lastUpdatedAt = row.last_updated_at ?? undefined;
|
|
209
|
-
const quota = row.quota_state ?
|
|
325
|
+
const quota = row.quota_state ? publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at })) : undefined;
|
|
210
326
|
return {
|
|
211
327
|
provider: row.provider,
|
|
212
328
|
accountKey: row.account_key,
|
|
@@ -263,12 +379,14 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
263
379
|
const existing = getDb().query("SELECT * FROM provider_quotas WHERE provider = ? AND account_key = ?").get(provider, accountKey) as ProviderQuotaRow | undefined;
|
|
264
380
|
const sourceAgentIds = normalizeSourceAgentIds(existing?.source_agent_ids);
|
|
265
381
|
if (input.sourceAgentId && !sourceAgentIds.includes(input.sourceAgentId)) sourceAgentIds.push(input.sourceAgentId);
|
|
266
|
-
const
|
|
382
|
+
const existingQuota = existing?.quota_state ? parseStoredQuotaState(existing.quota_state, { windows: [], source: "probe", updatedAt: existing.last_updated_at ?? 0 }) : undefined;
|
|
383
|
+
const mergedQuota = input.quota ? mergeQuotaWindows(existingQuota, input.quota, provider, accountKey) : undefined;
|
|
384
|
+
const lastUpdatedAt = mergedQuota ? mergedQuota.quota.updatedAt : existing?.last_updated_at ?? undefined;
|
|
267
385
|
const lastAttemptAt = input.lastAttemptAt ?? input.quota?.updatedAt ?? now;
|
|
268
386
|
const lastError = input.lastError === undefined
|
|
269
387
|
? input.quota ? null : existing?.last_error ?? null
|
|
270
388
|
: input.lastError;
|
|
271
|
-
const quotaState =
|
|
389
|
+
const quotaState = mergedQuota ? JSON.stringify(mergedQuota.quota) : existing?.quota_state ?? null;
|
|
272
390
|
|
|
273
391
|
getDb().query(`
|
|
274
392
|
INSERT INTO provider_quotas (
|
|
@@ -295,19 +413,20 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
295
413
|
now,
|
|
296
414
|
);
|
|
297
415
|
|
|
298
|
-
|
|
299
|
-
|
|
416
|
+
const snapshotQuota = mergedQuota?.acceptedIncomingWindow ? publicQuotaState(mergedQuota.quota) : undefined;
|
|
417
|
+
if (snapshotQuota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(snapshotQuota)) {
|
|
418
|
+
recordProviderQuotaSnapshot({ provider, accountKey, quota: snapshotQuota, sourceAgentId: input.sourceAgentId }, now);
|
|
300
419
|
} else if (!input.quota && input.lastError?.type === "unavailable" && !latestSnapshotIsUnavailable(provider, accountKey)) {
|
|
301
420
|
// #609 — the provider couldn't be collected (orchestrator skip, #601). Record one explicit gap
|
|
302
421
|
// marker on the transition into unavailable (deduped while it stays unavailable) so history shows
|
|
303
422
|
// an honest gap rather than a silent absence; the next successful poll closes the gap.
|
|
304
423
|
recordProviderQuotaUnavailable({ provider, accountKey, error: input.lastError, sourceAgentId: input.sourceAgentId }, now);
|
|
305
424
|
}
|
|
306
|
-
if (hasFreshSuccessfulProviderQuota(input, now)) {
|
|
425
|
+
if (hasFreshSuccessfulProviderQuota(input, now) && mergedQuota?.acceptedIncomingWindow) {
|
|
307
426
|
const quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
|
|
308
427
|
provider,
|
|
309
428
|
accountKey,
|
|
310
|
-
previousQuota:
|
|
429
|
+
previousQuota: existingQuota ? publicQuotaState(existingQuota) : undefined,
|
|
311
430
|
quota: input.quota,
|
|
312
431
|
sourceAgentIds,
|
|
313
432
|
previousAdvisoryState: existing?.quota_advisory_state,
|
package/src/routes/index.ts
CHANGED
|
@@ -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";
|
|
@@ -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),
|
|
@@ -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);
|
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.
|